From ed142377a6743bdec37b075ae54cb44a1033303a Mon Sep 17 00:00:00 2001 From: grjoseph Date: Thu, 20 Aug 2026 19:06:34 -0700 Subject: [PATCH 1/9] feat: notify when a newer npm version of the server is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-out, notify-only update check that reads the published dist-tags for @microsoft/spe-mcp from the public npm registry and, when a newer release exists, appends one concise notice to a single tool result. The check is fire-and-forget: it never blocks a tool call, never writes to stdout, and never downloads, installs, or executes anything. Auto-update is out of scope. Behaviour - Channel-aware: a prerelease install is compared against its own dist-tag (e.g. alpha) and a newer stable `latest` is detected separately. - Strict, dependency-free SemVer 2.0.0 parsing and precedence comparison (src/semver.ts); zero new runtime dependencies (budget stays at 6, guarded by a packaging test). - Skipped automatically for source checkouts and in CI. - Owner-only cache under the data directory with a 24h TTL, a failure backoff, and per-target notification suppression so a given version is announced once. - `status_get` reports the running version, update state, cached latest, last check time, registry, cache path, and the opt-out controls — read from disk, never from the network. Privacy and security (SEC-008) - Requests exactly one fixed package path over HTTPS with no query string; redirects and cross-host responses are rejected. - Unauthenticated: `credentials: "omit"`, no Authorization header, no cookies, no .npmrc, no npm subprocess. - Sends no install GUID, machine, user, tenant, subscription, correlation, or session identifier. The static product User-Agent is omitted when telemetry is disabled. - Bounded by a 2s timeout and a 64KB response cap; hostile registry payloads are rejected by strict parsing and prototype-pollution-safe key filtering. - `SPE_NPM_REGISTRY` must be an https: URL with no credentials, query, or fragment; anything else disables the check for that run. - Before the first request in a process, a one-time collection notice naming the endpoint, the boundary, and the opt-out is printed to stderr. - Five zero-network opt-outs: SPE_MCP_UPDATE_CHECK=false (preferred), --no-update-check, SPE_NO_UPDATE_CHECK=1 (backward-compatible alias), NO_UPDATE_NOTIFIER=1, and SPE_MCP_COLLECT_TELEMETRY=false. When suppressed there is no request, no stderr notice, and no cache write. - The cache contains no identifier, is retained until deleted, and is removed by `spe-mcp logout` and `spe-mcp auth --reset`. Documentation - README, PRIVACY, docs/DATA-FLOW, docs/SECURITY-CONTROLS, docs/TROUBLESHOOTING, and CHANGELOG disclose that registry.npmjs.org (npm, Inc./GitHub) is outside the Microsoft 365 / Azure compliance boundary and outside EU Data Boundary commitments, that the connection discloses IP address, User-Agent, and request time, that the local cache is retained until deleted, and that there is no auto-update. - Known limitation, documented as an open tradeoff and not a sign-off: Node's built-in fetch ignores HTTP(S)_PROXY/NO_PROXY, so the check cannot be routed through an egress proxy. It fails closed. Adding proxy support would require a new runtime dependency, which is outside the dependency budget. Tests - src/semver.test.ts and src/update-check.test.ts cover SemVer edge cases and prerelease precedence, cache TTL/backoff/suppression, hostile registry data, timeout/offline/non-200/oversize responses, every opt-out, one-time notice behaviour, exact request URL and headers, redirect and cross-host rejection, telemetry opt-out, cache deletion on logout, first-run notice ordering, and offline status reporting. - status, packaging, and protocol e2e suites extended; no e2e test invokes the environment-dependent status_get path. AB#3219463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 41 ++ PRIVACY.md | 95 +++- README.md | 93 ++- docs/DATA-FLOW.md | 48 +- docs/SECURITY-CONTROLS.md | 1 + docs/TROUBLESHOOTING.md | 37 ++ src/cli.ts | 79 ++- src/index.ts | 45 +- src/packaging.test.ts | 49 ++ src/paths.ts | 13 + src/protocol-e2e.test.ts | 23 + src/semver.test.ts | 246 ++++++++ src/semver.ts | 168 ++++++ src/tools/status.test.ts | 76 +++ src/tools/status.ts | 64 ++- src/types.ts | 8 + src/update-check.test.ts | 1130 +++++++++++++++++++++++++++++++++++++ src/update-check.ts | 914 ++++++++++++++++++++++++++++++ src/version.ts | 14 +- 19 files changed, 3100 insertions(+), 44 deletions(-) create mode 100644 src/semver.test.ts create mode 100644 src/semver.ts create mode 100644 src/update-check.test.ts create mode 100644 src/update-check.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec93124..3c05a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Update awareness (notify only).** The server now checks the public npm registry at + most once every 24 hours and, when a newer release of `@microsoft/spe-mcp` exists, + appends a single concise `Update available: …` notice to one tool result (plus an + optional `structuredContent.updateAvailable` payload). The check is fire-and-forget — + it never blocks a tool call, never writes to stdout, and **never downloads, installs, or + executes anything**; auto-update is explicitly out of scope. It is channel-aware (an + `alpha` install is compared against the `alpha` dist-tag, and a newer `latest` is + reported separately), adds **zero new runtime dependencies**, and is skipped + automatically in CI and when running from a source checkout. Disable it with + `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, `SPE_NO_UPDATE_CHECK=1` + (backward-compatible alias), `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false`; + when disabled, **no network request, stderr notice, or cache write occurs**. Point it at a + mirror with `SPE_NPM_REGISTRY` (HTTPS-only). +- **Transparency for the update check.** Before the first registry request in a process, the + server prints a one-time **stderr** collection notice naming the endpoint, the boundary, and + the opt-out. `status_get` now reports the running server version, the update-check state, + the locally cached latest version, the time of the last check, the registry in use, the + cache-file path, and the opt-out controls — all read from disk, with **no network access**. +- **Update-check cache lifecycle.** The cached result at `/update-check.json` + contains **no identifier** and is retained until deleted; `spe-mcp logout` and + `spe-mcp auth --reset` now remove it alongside the cached tokens. +- **Boundary disclosure.** `README.md`, `PRIVACY.md`, `docs/DATA-FLOW.md`, + `docs/SECURITY-CONTROLS.md`, and `docs/TROUBLESHOOTING.md` document that + `registry.npmjs.org` (npm, Inc./GitHub) is the only endpoint **outside the Microsoft 365 / + Azure compliance boundary** and outside EU Data Boundary commitments, that the connection + discloses IP address / static `User-Agent` / request time, that no auto-update exists, and + that Node's built-in `fetch` cannot route through `HTTP(S)_PROXY` — an open, unresolved + tradeoff accepted to preserve the zero-runtime-dependency budget. - **Per-instance data directory.** New `--data-dir ` flag and `SPE_DATA_DIR` environment variable select where the provisioning `state.json` and MSAL token cache are stored (precedence: flag > env > default `~/.spe-mcp`). Point each @@ -18,6 +46,19 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Security +- **Update-check hardening (SEC-008).** The npm version check is HTTPS-only and requests + exactly one fixed package path with no query string; redirects and cross-host responses are + rejected. It is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no + `.npmrc`, no `npm` subprocess), sends **no install GUID, machine, user, tenant, subscription, + correlation, or session identifier**, and omits the product `User-Agent` when + `SPE_MCP_COLLECT_TELEMETRY=false`. It is bounded by a 2-second timeout and a 64 KB response + cap, parsed with strict SemVer and prototype-pollution-safe key filtering, and cached + owner-only (SEC-003) with a 24-hour TTL and a failure backoff, deleted on `logout` / + `auth --reset`. `SPE_NPM_REGISTRY` values carrying credentials, a query string, or a + fragment are rejected. **Known limitation:** Node's built-in `fetch` ignores + `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it + fails closed. + - **Fail-closed credential/state file handling.** The data directory and token cache files are now validated fail-closed: a symlinked, foreign-owned, or group/other-accessible directory is refused (POSIX `0o700`); an off-`%USERPROFILE%` diff --git a/PRIVACY.md b/PRIVACY.md index 7ef19b5..9a59d2e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,7 +12,9 @@ organization's agreements with Microsoft. **The tool opens no dedicated usage-analytics channel and sends no personal, tenant, or per-user data to Microsoft.** The only Microsoft-bound attribution signal is a static product `User-Agent` token, which is on by default and can be turned off (see -[Turning it off](#turning-it-off)). Specifically: +[Turning it off](#turning-it-off)). The only **non-Microsoft** destination is an anonymous +public-package lookup on the npm registry used to notify you of newer releases, which can +also be turned off. Specifically: - **No telemetry channel.** The tool does not implement application telemetry and does not "phone home." Diagnostic logs are written to the local process's **stderr only**, with @@ -32,6 +34,70 @@ per-user data to Microsoft.** The only Microsoft-bound attribution signal is a s aggregate traffic driven by this tool. It is a request header on calls you already make — not a separate data feed — and it is **on by default**; set `SPE_MCP_COLLECT_TELEMETRY=false` to omit it (see [Turning it off](#turning-it-off)). +- **Update check (public npm registry — the only non-Microsoft destination, and the only + destination outside the compliance boundary).** At most once every 24 hours the tool reads + the published version list for `@microsoft/spe-mcp` from the public npm registry + (`https://registry.npmjs.org`, override with `SPE_NPM_REGISTRY`) so it can tell you when a + newer release exists (`src/update-check.ts`). + + > **Boundary disclosure.** `registry.npmjs.org` is operated by **npm, Inc. (GitHub)**, not by + > Microsoft 365 or Azure. It is **outside the Microsoft 365 / Azure compliance boundary** and + > outside any **EU Data Boundary** commitment that applies to your tenant. Data sent there is + > not covered by the Microsoft Product Terms or the DPA; it is governed by the + > [npm privacy policy](https://docs.npmjs.com/policies/privacy). + + **Exactly one request is made,** to the exact package path with no query string and no + fragment: + + ```text + GET https://registry.npmjs.org/@microsoft%2fspe-mcp + ``` + + **What the third party can see.** The request is an **anonymous, unauthenticated HTTP GET of + public package metadata** — the same lookup `npm view` performs. The request body and headers + carry no identifiers, but the connection itself necessarily discloses to npm: + + | Disclosed to npm | Why | + |------------------|-----| + | Your **IP address** (or your egress/NAT address) | Inherent to making an HTTPS connection | + | The **package name** `@microsoft/spe-mcp` | It is the resource being requested | + | The static product **`User-Agent`** `spe-mcp-server/` | Standard client identification; **omitted entirely** when `SPE_MCP_COLLECT_TELEMETRY=false` | + | Approximate **time of the request** | Inherent to any server-side request log | + + **What is never sent:** no credentials, tokens, cookies, or `Authorization` header; no + `.npmrc` and no npm subprocess; **no install GUID, machine identifier, hostname, user name, + tenant ID, subscription ID, correlation ID, or session ID**; no usage, prompt, or content + data; no data about which tools you invoked. The tool generates and stores **no identifier of + any kind** for this feature. Redirects are rejected outright, so the request cannot be + bounced to a different host. + + **No auto-update.** Nothing is downloaded, installed, executed, or modified. The tool only + *notifies* you; applying an update is always a manual `npm install` you run yourself. + + **Local retention.** The result is cached on your machine at + `/update-check.json` (owner-only permissions, control **SEC-008**; + `` is `%LOCALAPPDATA%\spe-mcp` on Windows or `~/.local/share/spe-mcp` elsewhere, + and is reported by `status_get`). The cache contains only the checked version strings, the + registry URL, a timestamp, and which versions you have already been told about — **no + identifier**. It is **retained locally until you delete it**: there is no automatic expiry of + the file itself, only of its freshness. Run `spe-mcp logout` or `spe-mcp auth --reset` to + delete it, or remove the file by hand. + + **First-run notice.** Before the **first** network request in a process, the tool prints a + one-time notice to **stderr** naming the endpoint, the boundary, and how to turn the check + off. No notice is printed when the check is disabled or served from cache. + + **Turning it off.** The check is **skipped automatically** in CI and when running from a + source checkout, and can be disabled outright (see [Turning it off](#turning-it-off)); when + disabled, **no request is made, no notice is printed, and no cache file is written**. + + **Known limitation (proxy).** The check uses the Node.js built-in `fetch`, which does **not** + honour `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`. On a network that requires an egress proxy + the request simply fails and is silently ignored (fail-closed — no data leaves by another + route), but it also means the check **cannot be routed through your proxy for inspection or + policy enforcement**. Adding proxy support would require a new runtime dependency, which this + project deliberately avoids. This is recorded as an **open, unresolved tradeoff**; if your + environment requires all egress to be proxied, disable the check. See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full list of network endpoints and what travels to each. @@ -68,7 +134,32 @@ Microsoft-bound telemetry signal, and it is **on by default**. To opt out, set outbound Graph and Azure Resource Manager requests. Those requests still go out — they simply carry the underlying tool's default `User-Agent` instead (e.g. the Azure CLI's own token for `az`/`azd`, or the Node runtime default for direct Graph calls), whose logging is governed by -those services' own terms. To further limit +those services' own terms. + +The **update check** — the only non-Microsoft outbound call, and the only call that leaves the +Microsoft 365 / Azure compliance boundary — is on by default in published installs. Any one of +the following disables it completely: + +| Opt-out | Effect | +|---------|--------| +| `SPE_MCP_UPDATE_CHECK=false` | **Preferred.** Disables the check for every instance in that environment (`0`, `off`, `no` also accepted) | +| `spe-mcp start --no-update-check` | Disables the check for that server instance | +| `SPE_NO_UPDATE_CHECK=1` | Backward-compatible alias, honoured identically | +| `NO_UPDATE_NOTIFIER=1` | Community-standard opt-out, honoured identically | +| `SPE_MCP_COLLECT_TELEMETRY=false` | Opting out of the product `User-Agent` also disables the update check | + +When disabled, the tool makes **no registry request, prints no collection notice, and writes no +update-check cache file** — the code path exits before any network or disk access. `status_get` +still reports the state, reading only what is already on disk. + +The check is also skipped automatically in CI (`CI`, `GITHUB_ACTIONS`, `TF_BUILD`, …) and when +the server is run from a source checkout rather than an installed package. + +To delete data already cached by the check, run `spe-mcp logout` or `spe-mcp auth --reset` — +both remove `/update-check.json` along with the cached authentication tokens. You can +also delete the file by hand; `status_get` prints its full path. + +To further limit outbound calls you can run with `--read-only` (no mutating operations) or `--tools` (restrict the exposed tool set, including the optional Microsoft Learn documentation lookup). See [docs/DATA-FLOW.md](docs/DATA-FLOW.md), [docs/SECURITY-CONTROLS.md](docs/SECURITY-CONTROLS.md), diff --git a/README.md b/README.md index c584ad6..08c01ee 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,70 @@ without a global install. Pin a specific version with `@microsoft/spe-mcp@0.1.0-alpha.1`. To remove the server, delete the MCP client config entry. +### Update notifications + +To make it obvious when you are running an old build, the server checks the +public npm registry **once a day, in the background**, for a newer published +release and — if one exists — appends a short notice to a single tool result: + +```text +Update available: @microsoft/spe-mcp 0.2.0-alpha.1 -> 0.2.0-alpha.4 (alpha channel). +Update with: npm install -g @microsoft/spe-mcp@alpha +``` + +The current version and the update state are also reported by `status_get`, so +they are always available for a bug report. + +How it behaves: + +- **Notify only — the server never updates itself.** Nothing is downloaded, + installed, or executed; you choose when to update. There is no auto-update. +- **Never blocks a tool call.** The check is fire-and-forget with a 2-second + timeout; if the registry is slow or unreachable, the result is simply dropped. +- **Channel-aware.** A prerelease install (e.g. `alpha`) is compared against its + own dist-tag, and a newer **stable** release is mentioned separately. +- **Quiet.** The notice is shown once per newer version, not on every call. +- **Anonymous.** Exactly one unauthenticated `GET` of the package's public + metadata — `https://registry.npmjs.org/@microsoft%2fspe-mcp`, no query string, + redirects rejected. No credentials, cookies, `.npmrc`, or `npm` subprocess are + involved, and **no install GUID, machine, user, tenant, subscription, or + session identifier** is sent. As with any HTTPS request, npm sees your IP + address, the static `User-Agent` `spe-mcp-server/` (omitted when + telemetry is off), and the time of the request. +- **Announced.** Before the first check in a process, a one-time notice is + printed to **stderr** naming the endpoint, the boundary, and the opt-out. +- **Cached locally.** The result is stored owner-only at + `/update-check.json` and **kept until you delete it**; + `spe-mcp logout` and `spe-mcp auth --reset` remove it, and `status_get` prints + the path. + +> ⚠️ **Boundary note.** `registry.npmjs.org` is operated by npm, Inc. (GitHub). +> It is the **only** endpoint this server contacts that is **outside the +> Microsoft 365 / Azure compliance boundary** and outside EU Data Boundary +> commitments. Disable the update check to remove it entirely. + +> **Known limitation.** Node's built-in `fetch` does not honour `HTTP_PROXY` / +> `HTTPS_PROXY` / `NO_PROXY`, so this request cannot be routed through an egress +> proxy for inspection. It fails closed — the check is skipped, and no data +> leaves by another route. Adding proxy support would require a new runtime +> dependency, which this project avoids; this is an open, unresolved tradeoff. +> Disable the check in environments where all egress must be proxied. + +It is skipped automatically when the server is run from a source checkout or in +CI, and can be turned off explicitly: + +```bash +SPE_MCP_UPDATE_CHECK=false spe-mcp start # preferred env var +spe-mcp start --no-update-check # flag +SPE_NO_UPDATE_CHECK=1 spe-mcp start # backward-compatible alias +NO_UPDATE_NOTIFIER=1 spe-mcp start # community-standard opt-out +SPE_MCP_COLLECT_TELEMETRY=false spe-mcp start # telemetry opt-out also disables it +``` + +When disabled, **no network request, no stderr notice, and no cache write happen +at all**. See [PRIVACY.md](PRIVACY.md) and +[docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full data-flow description. + ## Prerequisites - **Node.js** 22, 24, or 26 @@ -198,7 +262,9 @@ The server accepts configuration via CLI flags or environment variables: | `--read-only` | `SPE_READ_ONLY` | Advertise/allow only read/list/get/search tools; reject mutating calls | | `--tools` | `SPE_TOOLS` | Restrict exposed tools to a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated tool list | | `--data-dir` | `SPE_DATA_DIR` | Directory for the token cache + provisioning state (default `~/.spe-mcp`). Point each instance at a unique **absolute** path (or `~/...`; CWD-relative paths are rejected) to run multiple servers without clobbering state | -| _(none)_ | `SPE_MCP_COLLECT_TELEMETRY` | Product `User-Agent` attribution token on outbound Graph/ARM requests. On by default; set to `false` to opt out (see [PRIVACY.md](PRIVACY.md)) | +| `--no-update-check` | `SPE_MCP_UPDATE_CHECK=false` | Disable the once-a-day npm version check that tells you when a newer server release is published (see [Update notifications](#update-notifications)). Also honours `SPE_NO_UPDATE_CHECK=1` (alias), the community-standard `NO_UPDATE_NOTIFIER=1`, and `SPE_MCP_COLLECT_TELEMETRY=false`. When disabled, no network request, stderr notice, or cache write occurs | +| _(none)_ | `SPE_NPM_REGISTRY` | Registry base URL for the update check (default `https://registry.npmjs.org` — npm, Inc./GitHub, **outside the Microsoft 365 / Azure compliance boundary**). **HTTPS only**; credentials, query strings, and fragments are rejected | +| _(none)_ | `SPE_MCP_COLLECT_TELEMETRY` | Product `User-Agent` attribution token on outbound Graph/ARM requests. On by default; set to `false` to opt out — this also disables the update check entirely (see [PRIVACY.md](PRIVACY.md)) | > The CLI flag wins when both a flag and its env var are set. Run > `spe-mcp start --help` to see the authoritative option list and descriptions. @@ -278,7 +344,7 @@ Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/App ```bash # Start the MCP server (stdio transport) -spe-mcp start [--client-id ID] [--tenant-id ID] [--read-only] [--tools ] +spe-mcp start [--client-id ID] [--tenant-id ID] [--read-only] [--tools ] [--no-update-check] # Authenticate interactively (cache tokens for headless use) spe-mcp auth --client-id ID --tenant-id ID [--reset] @@ -296,6 +362,7 @@ Every command has built-in help — run `spe-mcp --help` (e.g. | `--tenant-id ` | Entra ID Tenant ID. Discovered from the Azure CLI when omitted. | | `--read-only` | Read-only mode: only read/list/get/search tools are exposed and callable. | | `--tools ` | Tool allowlist: a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated list of tool names. | +| `--no-update-check` | Disable the daily npm version check (see [Update notifications](#update-notifications)). | ## Authentication @@ -602,6 +669,28 @@ details see [PRIVACY.md](PRIVACY.md) and [docs/DATA-FLOW.md](docs/DATA-FLOW.md); handling of data you send to its online services is described in the [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement). +The one **non-Microsoft** destination is the public npm registry +(`https://registry.npmjs.org`, operated by npm, Inc./GitHub), contacted at most once a day by +the [update check](#update-notifications) to read the published version list for +`@microsoft/spe-mcp`. ⚠️ This endpoint is **outside the Microsoft 365 / Azure compliance +boundary**, is not covered by the Microsoft Product Terms or DPA, and is outside EU Data +Boundary commitments. The request is **unauthenticated and anonymous** — exactly +`GET https://registry.npmjs.org/@microsoft%2fspe-mcp` with no query string, no credentials or +cookies, redirects rejected, and **no install GUID, machine, user, tenant, subscription, +correlation, or session identifier**; it is an ordinary public package lookup, identical to +what `npm view` would issue. As with any HTTPS request, npm can observe your **IP address**, +the static `User-Agent` (omitted when telemetry is off), and the **time of the request**; +those are disclosed by the connection itself, not added by this tool. Nothing is downloaded, +installed, or executed — there is **no auto-update**. Before the first check, a one-time +notice naming the endpoint and the opt-out is printed to **stderr**. Disable it with +`SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, `SPE_NO_UPDATE_CHECK=1`, +`NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false`; when disabled, the request is +never made and nothing is cached. It is also skipped automatically in CI and in source +checkouts. The cached result lives at `/update-check.json`, is retained until you +delete it, and is removed by `spe-mcp logout` / `spe-mcp auth --reset`. npm's own handling of +registry requests is governed by the +[npm privacy policy](https://docs.npmjs.com/policies/privacy). + **Data collection (standard Microsoft notice).** The software may collect information about you and your use of the software and send it to Microsoft; Microsoft may use this information to provide and improve products and services, and your use of the software operates as your diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index ee36255..d520b6f 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -13,8 +13,10 @@ MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft - The server is a **local** process. It talks to your MCP client over **stdio**; it opens no network socket for the client connection. -- Every outbound network call is HTTPS to a **Microsoft-operated** endpoint, made **on your - behalf**, using **your** credentials, into **your** tenant and subscription. +- Every outbound network call is HTTPS. All calls that carry your data go to a + **Microsoft-operated** endpoint, made **on your behalf**, using **your** credentials, into + **your** tenant and subscription. The single exception is an anonymous public-package + lookup on the npm registry (below), which carries no data of yours. ## Outbound endpoints @@ -24,10 +26,30 @@ MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft | Microsoft Graph (`graph.microsoft.com`) | Create/manage app registrations, container types, containers, and content | Your delegated token | The requests you invoke, in your tenant | Microsoft first-party, in-tenant | | Azure Resource Manager (`management.azure.com`) | Register the `Microsoft.Syntex` provider and wire SPE billing to your subscription | Your Azure token | ARM requests in your subscription | Microsoft first-party, in-subscription | | Microsoft Learn MCP (`learn.microsoft.com/api/mcp`) | Read-only public documentation lookup (`docs_search`) | **None** | Documentation queries only — **no customer data** | Microsoft first-party, public docs | +| npm registry (`registry.npmjs.org`, override `SPE_NPM_REGISTRY`) | Update check: read the published version list for `@microsoft/spe-mcp` at most once per 24 h (control **SEC-008**) | **None** | Package name in the request path only. Necessarily discloses your **IP address**, the static **`User-Agent`** `spe-mcp-server/` (omitted when telemetry is off), and the request time. **No** customer data, tenant/user/subscription identifier, install GUID, machine name, session ID, or usage data | ⚠️ **Third party (npm, Inc. / GitHub) — OUTSIDE the Microsoft 365 / Azure compliance boundary and outside EUDB** | -The server contacts **no non-Microsoft services**. The Microsoft Learn documentation lookup -is the only unauthenticated, out-of-tenant call; it carries no customer data, is host- -validated before use (control **SEC-007**), and can be disabled with `--tools`. +Only two calls leave your tenant, and neither carries customer data: + +- The **Microsoft Learn documentation lookup** is unauthenticated and out-of-tenant; it is + host-validated before use (control **SEC-007**) and can be disabled with `--tools`. +- The **npm update check** is the only **non-Microsoft** destination and the only endpoint + **outside the Microsoft 365 / Azure compliance boundary**. It issues exactly one request — + `GET https://registry.npmjs.org/@microsoft%2fspe-mcp`, the exact package path with no query + string and no fragment — the same request `npm view` issues, with a 2-second timeout, a 64 KB + response cap, HTTPS enforced, **redirects to any other host rejected**, no + credentials/cookies/`Authorization`/`.npmrc`, and no `npm` subprocess. It only *notifies*; + **nothing is downloaded, installed, or executed — there is no auto-update**. Before the first + such request in a process, a one-time notice naming the endpoint, the boundary, and the + opt-out is printed to **stderr**. It is skipped automatically in CI and source checkouts, and + disabled by `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, + `SPE_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false` — in + which case no request is made, no notice is printed, and no cache is written. See + [PRIVACY.md](../PRIVACY.md). + - **Known limitation:** Node's built-in `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` / + `NO_PROXY`, so this request cannot be routed through an egress proxy for inspection. It + fails closed (the check is silently skipped) rather than falling back to another route. + Fixing this would require a new runtime dependency, which the project avoids; recorded as + an **open, unresolved tradeoff**. Disable the check where all egress must be proxied. ## Local artifacts @@ -35,6 +57,12 @@ These never leave your machine: - The MSAL **token cache** and the **provisioning-state** file, written owner-only (control **SEC-003**). +- The **update-check cache** (`update-check.json` in the data directory), written owner-only + (control **SEC-008**). It holds only the last-checked timestamp, the registry base URL, the + version that was current at check time, the published version strings, and which versions you + have already been notified about — **no identifiers of any kind**. It is **retained until you + delete it**; `spe-mcp logout` and `spe-mcp auth --reset` remove it, and `status_get` prints + its full path. - **stderr** diagnostic logs, with tokens and secrets redacted (`src/logging.ts`). ## Compliance boundary and EU Data Boundary (EUDB) @@ -42,6 +70,12 @@ These never leave your machine: - Microsoft Graph, Azure Resource Manager, and SharePoint Embedded are Microsoft Online Services operating **within the Microsoft 365 / Azure compliance boundary**. Requests you make through this tool stay within that boundary and your tenant's configured data location. +- ⚠️ **One endpoint is outside that boundary:** the npm registry (`registry.npmjs.org`), + operated by npm, Inc. (GitHub). It is **not** a Microsoft Online Service, is **not** covered + by the Microsoft Product Terms or the DPA, and is **not** subject to any **EU Data Boundary** + commitment applying to your tenant. Only the package name is requested; the connection + discloses your IP address, the static `User-Agent`, and the request time. Disable the update + check to remove this endpoint entirely. - The tool performs **no independent cross-region processing** and stores **no customer content** of its own. Data location, residency, and **EU Data Boundary** commitments are determined by those underlying services and your tenant configuration — not by this tool. @@ -55,5 +89,7 @@ stamped on outbound Graph/ARM requests. It is **on by default**; set `SPE_MCP_COLLECT_TELEMETRY=false` to omit it. Opting out neither silences the request nor adds a new signal — outbound calls simply fall back to the underlying tool's default `User-Agent` (the Azure CLI's own token for `az`/`azd`; the Node runtime default for direct Graph calls), -whose logging is governed by those services' own terms. See [PRIVACY.md](../PRIVACY.md) for +whose logging is governed by those services' own terms. The npm update check is **not** +telemetry: it is an inbound-information request (does a newer version exist?) that transmits +nothing about you and can be disabled independently. See [PRIVACY.md](../PRIVACY.md) for details. diff --git a/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md index 56f5d63..6d390ed 100644 --- a/docs/SECURITY-CONTROLS.md +++ b/docs/SECURITY-CONTROLS.md @@ -24,6 +24,7 @@ that maps each code to a human-readable name and a one-line description. | SEC-002 | Client-safe error messages | Tool `catch` blocks surface only sanitized, consistent messages to clients; internal detail stays in server-side logs. | | SEC-003 | Secure filesystem (owner-only) | Credential and state files (token cache, server state) are written owner-only (POSIX `0o600`; ACL-governed on Windows). | | SEC-007 | Docs endpoint validation | The Microsoft Learn MCP endpoint is resolved and validated before use to prevent redirection to an untrusted host. | +| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data) and omits the product `User-Agent` when telemetry is opted out, is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), cached owner-only via SEC-003 with a 24 h TTL and failure backoff and deleted on `logout` / `auth --reset`, announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | > Adding a new safeguard? Give it the next code in its family and add a row here > so code comments and tests have a lookup. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 55aef35..edf8c1b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -89,6 +89,43 @@ content_access_grant confirm=true Access can be disabled later with `content_access_revoke`. +## Update notice: missing, stale, or registry unreachable + +The server checks the public npm registry at most once every 24 hours and, if a newer release +exists, appends a one-line `Update available: …` notice to a single tool result. It never +blocks, never retries in-band, and **never updates itself** — there is no auto-update. + +Common situations: + +- **No notice appears, but a newer version exists.** The check is skipped by design when + running from a source checkout, in CI (`CI`, `GITHUB_ACTIONS`, `TF_BUILD`, …), or when any + opt-out is set: `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, + `SPE_NO_UPDATE_CHECK=1` (alias), `NO_UPDATE_NOTIFIER=1`, or + `SPE_MCP_COLLECT_TELEMETRY=false`. The notice is also shown only once per detected version + per cache. Run `status_get` to see the **Update check** row, which reports the exact state + and skip reason. When skipped, **no network request, stderr notice, or cache write occurs**. +- **Offline, proxied, or firewalled registry.** The lookup has a 2-second timeout and fails + silently; the failure is cached so the server does not retry on every call. `status_get` + reports `— unavailable (registry not reachable)`. This is harmless — no functionality + depends on it. +- **The check never succeeds behind an egress proxy.** Node's built-in `fetch` does **not** + honour `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, so the request cannot be routed through a + proxy. It fails closed — nothing leaves by another route. Adding proxy support would require + a new runtime dependency, which this project avoids; this is an open, unresolved tradeoff. + In proxy-only environments, disable the check with `SPE_MCP_UPDATE_CHECK=false`. +- **Internal/mirror registry.** Set `SPE_NPM_REGISTRY` to your mirror. It must be an `https:` + URL with no embedded credentials, query string, or fragment; anything else is ignored and + the check is disabled for that run. Redirects and cross-host responses are rejected. +- **A one-time stderr notice appeared at startup.** Before the first registry request, the + server prints a single collection notice to **stderr** naming the endpoint + (`registry.npmjs.org`, npm, Inc./GitHub — **outside the Microsoft 365 / Azure compliance + boundary**) and the opt-out. It is informational; stdout is never written to. Set any opt-out + above to suppress it entirely. +- **Delete the cached update state.** The cache lives at `/update-check.json` (path + shown by `status_get`), contains **no identifier**, and is retained until removed. Delete it + with `spe-mcp logout`, `spe-mcp auth --reset`, or by removing the file manually. Deleting it + also forces a re-check on the next start. + ## Correlation IDs When a tool fails, the client-facing error carries a short **correlation ID**, diff --git a/src/cli.ts b/src/cli.ts index 0e3e73d..e583be1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -76,33 +76,49 @@ program "Restrict exposed tools: a built-in profile (readOnly, docsOnly, provisioning, content, admin) or a comma-separated list of tool names. Can also be set via SPE_TOOLS.", ) .option("--data-dir ", DATA_DIR_OPTION) - .action(async (options: { clientId?: string; tenantId?: string; readOnly?: boolean; tools?: string; dataDir?: string }) => { - try { - // Resolve + record the data dir FIRST, before importing ./index.js (which - // pulls in state.ts/auth.ts) so every entry point resolves the same dir. - await applyDataDir(options.dataDir); - const clientId = options.clientId || process.env.SPE_CLIENT_ID; - const tenantId = options.tenantId || process.env.SPE_TENANT_ID; - // Read-only: CLI flag wins; otherwise a truthy SPE_READ_ONLY env value. - const readOnly = options.readOnly === true || isTruthyEnv(process.env.SPE_READ_ONLY); - // Tool allowlist/profile: CLI flag wins; otherwise SPE_TOOLS env. - const tools = options.tools || process.env.SPE_TOOLS; - - // Both are optional. With no client-id the server runs in bootstrap mode: - // the Azure CLI provides the control-plane token and the owning app is - // provisioned on demand. - const { startServer } = await import("./index.js"); - await startServer({ clientId, tenantId, readOnly, tools }); - } catch (error) { - console.error("Failed to start SPE MCP server:"); - if (error instanceof Error) { - console.error(error.stack ?? error.message); - } else { - console.error(error); + .option( + "--no-update-check", + "Do not contact the public npm registry to check whether a newer version of this server has been published. Can also be set via SPE_NO_UPDATE_CHECK or NO_UPDATE_NOTIFIER (truthy).", + ) + .action( + async (options: { + clientId?: string; + tenantId?: string; + readOnly?: boolean; + tools?: string; + dataDir?: string; + updateCheck?: boolean; + }) => { + try { + // Resolve + record the data dir FIRST, before importing ./index.js (which + // pulls in state.ts/auth.ts) so every entry point resolves the same dir. + await applyDataDir(options.dataDir); + const clientId = options.clientId || process.env.SPE_CLIENT_ID; + const tenantId = options.tenantId || process.env.SPE_TENANT_ID; + // Read-only: CLI flag wins; otherwise a truthy SPE_READ_ONLY env value. + const readOnly = options.readOnly === true || isTruthyEnv(process.env.SPE_READ_ONLY); + // Tool allowlist/profile: CLI flag wins; otherwise SPE_TOOLS env. + const tools = options.tools || process.env.SPE_TOOLS; + // Update awareness: commander sets updateCheck=false for --no-update-check. + // Environment opt-outs are applied inside the update checker itself. + const updateCheck = options.updateCheck !== false; + + // Both are optional. With no client-id the server runs in bootstrap mode: + // the Azure CLI provides the control-plane token and the owning app is + // provisioned on demand. + const { startServer } = await import("./index.js"); + await startServer({ clientId, tenantId, readOnly, tools, updateCheck }); + } catch (error) { + console.error("Failed to start SPE MCP server:"); + if (error instanceof Error) { + console.error(error.stack ?? error.message); + } else { + console.error(error); + } + process.exitCode = 1; } - process.exitCode = 1; - } - }); + }, + ); program .command("auth") @@ -131,6 +147,8 @@ program setInteractiveMode(); if (options.reset) { await clearCachedToken(); + const { removeUpdateCache } = await import("./update-check.js"); + removeUpdateCache(); console.log("Cleared cached tokens before authenticating."); } await authenticateInteractively(); @@ -148,7 +166,7 @@ program program .command("logout") - .description("Clear cached authentication tokens") + .description("Clear cached authentication tokens and the local update-check cache") .option("--data-dir ", DATA_DIR_OPTION) .action(async (options: { dataDir?: string }) => { try { @@ -157,7 +175,12 @@ program await applyDataDir(options.dataDir); const { clearCachedToken } = await import("./auth.js"); await clearCachedToken(); - console.log("Logged out. Cached tokens have been cleared."); + // Signing out clears every file this server wrote under the data dir, + // including the update-check cache (which holds no identifiers, but is + // still local state the user asked us to forget). + const { removeUpdateCache } = await import("./update-check.js"); + removeUpdateCache(); + console.log("Logged out. Cached tokens and the update-check cache have been cleared."); } catch (error) { console.error("Failed to clear cached tokens:"); if (error instanceof Error) { diff --git a/src/index.ts b/src/index.ts index 4e3a013..f86605f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { byoAppStartupNote, azLoginNotSignedInMessage } from "./onboarding-messa import { readState } from "./state.js"; import { productUserAgent, isProductUserAgent } from "./user-agent.js"; import { PACKAGE_VERSION } from "./version.js"; +import { startUpdateCheck, takePendingUpdateNotice, type UpdateAvailable } from "./update-check.js"; import type { McpTool, ServerConfig } from "./types.js"; import { createLogger } from "./logger.js"; import { redact } from "./logging.js"; @@ -206,6 +207,33 @@ function withDuration(structuredContent: unknown, durationMs: number): unknown { return { data: structuredContent, durationMs }; } +/** + * SEC-008 update awareness. When a background npm check has found a newer + * published version, append exactly one short notice to the next successful + * tool result and mirror it into `structuredContent.updateAvailable`. + * + * The notice is consumed by the first caller, so it appears once per process. + * This never waits on the network: `takePendingUpdateNotice()` only reads + * already-resolved in-memory state and returns `null` when the check is + * disabled, still running, failed, or found nothing newer. + */ +function withUpdateNotice( + content: { type: "text"; text: string }[], + structuredContent: unknown, +): { content: { type: "text"; text: string }[]; structuredContent: unknown } { + const notice = takePendingUpdateNotice(); + if (!notice) return { content, structuredContent }; + const updateAvailable: UpdateAvailable = notice.updateAvailable; + const merged = + structuredContent && typeof structuredContent === "object" && !Array.isArray(structuredContent) + ? { ...(structuredContent as Record), updateAvailable } + : structuredContent; + return { + content: [...content, { type: "text" as const, text: notice.text }], + structuredContent: merged, + }; +} + function validateArgs(args: Record, tool: McpTool): { ok: true; args: Record } | { ok: false; result: ReturnType } { if (tool.validateArgs) { return { ok: true, args: tool.validateArgs(args) }; @@ -333,10 +361,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const result = await tool.handler(validated.args); const durationMs = Date.now() - startTime; log(`${name} completed in ${durationMs}ms`); + const mapped = result.content.map((c) => ({ type: "text" as const, text: c.text })); + const structured = withDuration(result.structuredContent, durationMs); + // Only decorate genuinely successful results, so the notice never rides + // along with an error payload (and is not consumed by one). + const decorated = result.isError === true + ? { content: mapped, structuredContent: structured } + : withUpdateNotice(mapped, structured); return { - content: result.content.map((c) => ({ type: "text" as const, text: c.text })), + content: decorated.content, isError: result.isError, - structuredContent: withDuration(result.structuredContent, durationMs), + structuredContent: decorated.structuredContent, } as const; } catch (error) { const safeError = toSafeError(error); @@ -437,6 +472,12 @@ export async function startServer(config: ServerConfig) { // reason as log() above: stdout carries the MCP JSON-RPC protocol only. console.error("[SPE MCP Server] Started and ready for connections"); + // SEC-008 update awareness. Fire-and-forget: never awaited, never blocks the + // handshake or any tool call, and every failure is swallowed inside. When the + // check finds a newer published version, a short notice is appended to the + // next successful tool result (see withUpdateNotice above). + startUpdateCheck({ enabled: config.updateCheck !== false }); + if (config.clientId) { // Bring-your-own-app mode: the caller has ALREADY pre-created an owning Entra // application (its client id supplied via --client-id / SPE_CLIENT_ID) and diff --git a/src/packaging.test.ts b/src/packaging.test.ts index 82578b6..0031c88 100644 --- a/src/packaging.test.ts +++ b/src/packaging.test.ts @@ -92,6 +92,55 @@ describe("packaging: complete metadata", () => { }); }); +/** + * SEC-008 (update awareness) supply-chain guard. + * + * The npm update check is deliberately implemented with the platform `fetch` + * and an in-repo SemVer parser so it adds ZERO runtime dependencies. A package + * that ships to developers' machines pays for every transitive dependency in + * audit surface, so the runtime dependency set is pinned here: adding one must + * be a conscious, reviewed decision that updates this test. + */ +describe("dependency hygiene: runtime dependency budget", () => { + const EXPECTED_RUNTIME_DEPENDENCIES = [ + "@azure/msal-node", + "@modelcontextprotocol/sdk", + "commander", + "open", + "zod", + "zod-to-json-schema", + ]; + + it("ships exactly the approved runtime dependencies", () => { + const actual = Object.keys(pkg.dependencies ?? {}).sort(); + expect(actual).toEqual([...EXPECTED_RUNTIME_DEPENDENCIES].sort()); + }); + + it("keeps the runtime dependency count at 6", () => { + expect(Object.keys(pkg.dependencies ?? {})).toHaveLength(6); + }); + + it("adds no update-check or semver dependency", () => { + // The update check must not reintroduce `semver`, `node-fetch`, `axios`, + // `update-notifier`, `boxen`, or similar — all are covered in-repo. + const banned = [ + "semver", + "node-fetch", + "axios", + "got", + "undici", + "update-notifier", + "latest-version", + "package-json", + "boxen", + ]; + const deps = Object.keys(pkg.dependencies ?? {}); + for (const name of banned) { + expect(deps, `${name} must not be a runtime dependency`).not.toContain(name); + } + }); +}); + describe("dependency hygiene: no deprecated uuid@8", () => { const major = (v: string): number => { const m = String(v).match(/\d+/); diff --git a/src/paths.ts b/src/paths.ts index fa042ac..43e74d3 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -173,6 +173,19 @@ export function getLegacyCacheFile(): string { return join(getDataDir(), "token-cache.json"); } +/** + * Update-awareness cache file (`/update-check.json`). + * + * Records the last npm dist-tag probe (its timestamp, outcome, and which target + * versions have already been announced) so the check runs at most once per TTL + * and each newer version is surfaced to the user exactly once. Written with the + * same owner-only secure-fs primitives as the token cache. See `update-check.ts` + * (SEC-008). + */ +export function getUpdateCacheFile(): string { + return join(getDataDir(), "update-check.json"); +} + /** * Test-only hooks. Not part of the public API. Used to reset the memoized state * between unit tests so env-var precedence and lazy re-resolution can be asserted diff --git a/src/protocol-e2e.test.ts b/src/protocol-e2e.test.ts index 315a809..fb88b56 100644 --- a/src/protocol-e2e.test.ts +++ b/src/protocol-e2e.test.ts @@ -79,6 +79,11 @@ describe("MCP protocol-level e2e (spawned dist/cli.js start)", () => { delete env.SPE_TENANT_ID; delete env.SPE_READ_ONLY; delete env.SPE_TOOLS; + // SEC-008: keep this suite hermetic. The update check is fire-and-forget and + // would otherwise reach registry.npmjs.org from a test process. Opting out + // also exercises the documented kill switch over the real wire. + env.SPE_NO_UPDATE_CHECK = "1"; + delete env.SPE_NPM_REGISTRY; transport = new StdioClientTransport({ command: process.execPath, @@ -210,4 +215,22 @@ describe("MCP protocol-level e2e (spawned dist/cli.js start)", () => { const sc = res.structuredContent as StructuredError | undefined; expect(sc?.error?.code).toBe("CONFIRMATION_REQUIRED"); }); + + // (g) SEC-008 update awareness: the opt-out must be total. With the kill + // switch set, no tool result may be decorated with an update notice or an + // `updateAvailable` payload. Only SAFE, no-network tools are used so the + // assertion stays hermetic and fast. + it("never appends an update notice to tool results when the check is disabled", async () => { + for (const name of ["content_access_grant", "this_tool_does_not_exist"]) { + const res = await client.callTool({ name, arguments: {} }, undefined, { + timeout: CALL_TIMEOUT_MS, + }); + const text = (res.content as Array<{ type: string; text: string }>) + .map((c) => c.text) + .join("\n"); + expect(text, `${name} leaked an update notice`).not.toMatch(/Update available:/i); + const sc = res.structuredContent as Record | undefined; + expect(sc?.updateAvailable, `${name} leaked an updateAvailable payload`).toBeUndefined(); + } + }); }); diff --git a/src/semver.test.ts b/src/semver.test.ts new file mode 100644 index 0000000..1428919 --- /dev/null +++ b/src/semver.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Unit tests for the strict, dependency-free SemVer 2.0.0 implementation used by + * the update-awareness check (SEC-008). + * + * The version strings compared here arrive from the public npm registry, so the + * parser is treated as a trust boundary: these tests assert that it is TOTAL + * (never throws), that it rejects everything outside the spec grammar instead of + * coercing it, and that precedence matches the ordering published in the spec. + */ + +import { describe, it, expect } from "vitest"; + +import { + MAX_VERSION_LENGTH, + compareSemver, + isNewer, + parseSemver, + releaseChannel, +} from "./semver.js"; + +/** Parse a version that the tests know is valid, failing loudly if it is not. */ +function parseOrThrow(input: string) { + const parsed = parseSemver(input); + if (!parsed) throw new Error(`expected '${input}' to parse as SemVer`); + return parsed; +} + +describe("parseSemver", () => { + it("parses a plain release", () => { + expect(parseSemver("1.2.3")).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: [], + build: [], + raw: "1.2.3", + }); + }); + + it("parses zeroes without treating them as leading zeroes", () => { + expect(parseSemver("0.0.0")).toMatchObject({ major: 0, minor: 0, patch: 0 }); + }); + + it("parses dot-separated prerelease identifiers", () => { + expect(parseSemver("0.2.0-alpha.1")).toMatchObject({ + major: 0, + minor: 2, + patch: 0, + prerelease: ["alpha", "1"], + build: [], + }); + }); + + it("parses build metadata separately from the prerelease", () => { + expect(parseSemver("1.0.0-rc.1+build.5")).toMatchObject({ + prerelease: ["rc", "1"], + build: ["build", "5"], + }); + }); + + it("preserves the exact input in raw", () => { + expect(parseOrThrow("10.20.30-beta.2+sha.abc").raw).toBe("10.20.30-beta.2+sha.abc"); + }); + + it.each([ + ["a leading v", "v1.2.3"], + ["a partial version", "1.2"], + ["a major-only version", "1"], + ["a caret range", "^1.2.3"], + ["a tilde range", "~1.2.3"], + ["an x-range", "1.2.x"], + ["a wildcard", "*"], + ["leading whitespace", " 1.2.3"], + ["trailing whitespace", "1.2.3 "], + ["an embedded newline", "1.2.3\n"], + ["a leading zero in major", "01.2.3"], + ["a leading zero in minor", "1.02.3"], + ["a leading zero in patch", "1.2.03"], + ["a leading zero in a numeric prerelease", "1.2.3-01"], + ["a negative number", "-1.2.3"], + ["a four-part version", "1.2.3.4"], + ["an empty prerelease", "1.2.3-"], + ["an empty prerelease identifier", "1.2.3-alpha..1"], + ["an empty build", "1.2.3+"], + ["a non-ASCII identifier", "1.2.3-\u00e9"], + ["an underscore in the prerelease", "1.2.3-alpha_1"], + ["a dist-tag name", "latest"], + ["the empty string", ""], + ])("rejects %s", (_label, input) => { + expect(parseSemver(input)).toBeNull(); + }); + + it.each([ + ["undefined", undefined], + ["null", null], + ["a number", 123], + ["an object", { major: 1 }], + ["an array", ["1.2.3"]], + ["a boolean", true], + ["a version-like object with toString", { toString: () => "1.2.3" }], + ])("rejects the non-string %s without throwing", (_label, input) => { + expect(() => parseSemver(input)).not.toThrow(); + expect(parseSemver(input)).toBeNull(); + }); + + it("accepts a version exactly at the length ceiling", () => { + const padding = "a".repeat(MAX_VERSION_LENGTH - "1.2.3-".length); + const input = `1.2.3-${padding}`; + expect(input).toHaveLength(MAX_VERSION_LENGTH); + expect(parseSemver(input)).not.toBeNull(); + }); + + it("rejects a version one character over the length ceiling before matching", () => { + const input = `1.2.3-${"a".repeat(MAX_VERSION_LENGTH)}`; + expect(input.length).toBeGreaterThan(MAX_VERSION_LENGTH); + expect(parseSemver(input)).toBeNull(); + }); + + it("rejects a pathological string quickly rather than backtracking", () => { + // A classic catastrophic-backtracking shape; the length guard must reject it + // before the regex ever runs. + const hostile = `1.2.3-${"a.".repeat(5_000)}`; + const started = Date.now(); + expect(parseSemver(hostile)).toBeNull(); + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it("rejects numeric components beyond the safe-integer range", () => { + const huge = "9".repeat(40); + expect(parseSemver(`${huge}.0.0`)).toBeNull(); + expect(parseSemver(`0.${huge}.0`)).toBeNull(); + expect(parseSemver(`0.0.${huge}`)).toBeNull(); + }); +}); + +describe("compareSemver", () => { + it("orders by major, then minor, then patch", () => { + expect(compareSemver(parseOrThrow("1.0.0"), parseOrThrow("2.0.0"))).toBe(-1); + expect(compareSemver(parseOrThrow("2.1.0"), parseOrThrow("2.0.9"))).toBe(1); + expect(compareSemver(parseOrThrow("2.1.3"), parseOrThrow("2.1.4"))).toBe(-1); + expect(compareSemver(parseOrThrow("2.1.3"), parseOrThrow("2.1.3"))).toBe(0); + }); + + it("ranks a prerelease below the matching release", () => { + expect(compareSemver(parseOrThrow("1.0.0-alpha"), parseOrThrow("1.0.0"))).toBe(-1); + expect(compareSemver(parseOrThrow("1.0.0"), parseOrThrow("1.0.0-alpha"))).toBe(1); + }); + + it("ignores build metadata for precedence (spec section 10)", () => { + expect(compareSemver(parseOrThrow("1.0.0+a"), parseOrThrow("1.0.0+b"))).toBe(0); + expect(compareSemver(parseOrThrow("1.0.0-rc.1+a"), parseOrThrow("1.0.0-rc.1+b"))).toBe(0); + }); + + it("reproduces the precedence chain published in the spec", () => { + const ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ].map(parseOrThrow); + + for (let index = 0; index < ordered.length - 1; index += 1) { + const lower = ordered[index]!; + const higher = ordered[index + 1]!; + expect(compareSemver(lower, higher)).toBe(-1); + expect(compareSemver(higher, lower)).toBe(1); + expect(compareSemver(lower, lower)).toBe(0); + } + }); + + it("ranks numeric prerelease identifiers below alphanumeric ones", () => { + expect(compareSemver(parseOrThrow("1.0.0-1"), parseOrThrow("1.0.0-alpha"))).toBe(-1); + }); + + it("compares numeric prerelease identifiers numerically, not lexically", () => { + expect(compareSemver(parseOrThrow("1.0.0-alpha.2"), parseOrThrow("1.0.0-alpha.10"))).toBe(-1); + }); + + it("compares numeric prerelease identifiers beyond MAX_SAFE_INTEGER exactly", () => { + const low = parseOrThrow("1.0.0-alpha.9007199254740993"); + const high = parseOrThrow("1.0.0-alpha.9007199254740994"); + expect(compareSemver(low, high)).toBe(-1); + expect(compareSemver(high, low)).toBe(1); + }); + + it("ranks a shorter prerelease list below an otherwise-equal longer one", () => { + expect(compareSemver(parseOrThrow("1.0.0-alpha"), parseOrThrow("1.0.0-alpha.1"))).toBe(-1); + }); +}); + +describe("isNewer", () => { + it("is true only for strictly higher precedence", () => { + const current = parseOrThrow("0.2.0-alpha.1"); + expect(isNewer(parseOrThrow("0.2.0-alpha.2"), current)).toBe(true); + expect(isNewer(parseOrThrow("0.2.0"), current)).toBe(true); + expect(isNewer(parseOrThrow("1.0.0"), current)).toBe(true); + expect(isNewer(parseOrThrow("0.2.0-alpha.1"), current)).toBe(false); + expect(isNewer(parseOrThrow("0.2.0-alpha.0"), current)).toBe(false); + expect(isNewer(parseOrThrow("0.1.9"), current)).toBe(false); + }); + + it("does not treat a build-metadata-only difference as newer", () => { + expect(isNewer(parseOrThrow("1.0.0+build.2"), parseOrThrow("1.0.0+build.1"))).toBe(false); + }); +}); + +describe("releaseChannel", () => { + it("returns the first prerelease identifier", () => { + expect(releaseChannel(parseOrThrow("0.2.0-alpha.1"))).toBe("alpha"); + expect(releaseChannel(parseOrThrow("1.4.0-beta"))).toBe("beta"); + expect(releaseChannel(parseOrThrow("2.0.0-rc.3"))).toBe("rc"); + expect(releaseChannel(parseOrThrow("2.0.0-next.1"))).toBe("next"); + }); + + it("lower-cases the identifier so it matches a dist-tag", () => { + expect(releaseChannel(parseOrThrow("1.0.0-Alpha.1"))).toBe("alpha"); + }); + + it("accepts a hyphenated channel name", () => { + expect(releaseChannel(parseOrThrow("1.0.0-next-major.1"))).toBe("next-major"); + }); + + it("returns null for a stable release", () => { + expect(releaseChannel(parseOrThrow("1.2.3"))).toBeNull(); + expect(releaseChannel(parseOrThrow("1.2.3+build.1"))).toBeNull(); + }); + + it("returns null for a purely numeric prerelease", () => { + expect(releaseChannel(parseOrThrow("1.0.0-1"))).toBeNull(); + expect(releaseChannel(parseOrThrow("1.0.0-0.3.7"))).toBeNull(); + }); + + it("returns null for an identifier that could not be a safe dist-tag", () => { + // Starts with a hyphen rather than a letter. + expect(releaseChannel(parseOrThrow("1.0.0--alpha"))).toBeNull(); + // Longer than the 32-character ceiling. + expect(releaseChannel(parseOrThrow(`1.0.0-${"a".repeat(33)}`))).toBeNull(); + }); +}); diff --git a/src/semver.ts b/src/semver.ts new file mode 100644 index 0000000..bd362f5 --- /dev/null +++ b/src/semver.ts @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Strict, dependency-free SemVer 2.0.0 parsing and precedence comparison. + * + * WHY THIS EXISTS: the update-awareness check (see `update-check.ts`, SEC-008) + * compares the installed version against version strings returned by the public + * npm registry. Those strings are UNTRUSTED REMOTE INPUT, so they need strict + * validation — not a lenient "coerce anything into a version" parse. Rather + * than take a new runtime dependency for ~100 lines of fully specified logic, + * the grammar from https://semver.org/spec/v2.0.0.html is implemented here. + * The server ships a deliberately small runtime dependency set and this feature + * adds nothing to it. + * + * SECURITY POSTURE: + * - {@link parseSemver} is total: it never throws and returns `null` for + * anything that is not an exact match for the spec grammar. Leading `v`, + * leading zeroes, ranges (`^1.2.3`), partials (`1.2`), and whitespace are all + * rejected rather than "helpfully" coerced. + * - Input longer than {@link MAX_VERSION_LENGTH} is rejected BEFORE the regex + * runs, so a hostile registry response cannot feed the matcher's nested + * quantifiers an unbounded string. + * - Numeric core components must be safe integers; absurd inputs like a + * 400-digit major version are rejected instead of silently losing precision. + * - Numeric prerelease identifiers are compared as digit strings (length, then + * lexicographic) rather than via `Number`, so precedence stays exact even for + * identifiers beyond `Number.MAX_SAFE_INTEGER`. + */ + +/** + * Maximum accepted length of a version string. Well beyond any real published + * version (npm's own limit is far lower) while bounding regex work on hostile + * input. + */ +export const MAX_VERSION_LENGTH = 256; + +/** + * The official SemVer 2.0.0 grammar, anchored. Capture groups: + * 1 major, 2 minor, 3 patch, 4 prerelease (dot-separated), 5 build metadata. + */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** A version string that matched the SemVer 2.0.0 grammar exactly. */ +export interface SemVer { + readonly major: number; + readonly minor: number; + readonly patch: number; + /** Dot-separated prerelease identifiers; empty for a stable release. */ + readonly prerelease: readonly string[]; + /** Dot-separated build metadata; ignored for precedence, per spec §10. */ + readonly build: readonly string[]; + /** The exact input string that produced this value. */ + readonly raw: string; +} + +/** A prerelease identifier consisting only of digits (spec: "numeric identifier"). */ +const NUMERIC_IDENTIFIER = /^\d+$/; + +/** + * A channel name usable as an npm dist-tag: starts with a letter, then letters, + * digits, or hyphens. Deliberately narrow so a hostile prerelease string can + * never be spliced into a registry lookup or a user-visible notice. + */ +const CHANNEL_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; + +/** + * Parse `input` as a SemVer 2.0.0 version. + * + * Returns `null` — never throws — for any non-string, empty string, over-long + * string, or string that does not match the grammar exactly. + */ +export function parseSemver(input: unknown): SemVer | null { + if (typeof input !== "string") return null; + if (input.length === 0 || input.length > MAX_VERSION_LENGTH) return null; + + const match = SEMVER_PATTERN.exec(input); + if (!match) return null; + + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if ( + !Number.isSafeInteger(major) || + !Number.isSafeInteger(minor) || + !Number.isSafeInteger(patch) + ) { + return null; + } + + return { + major, + minor, + patch, + prerelease: match[4] ? match[4].split(".") : [], + build: match[5] ? match[5].split(".") : [], + raw: input, + }; +} + +/** + * Compare two prerelease identifier lists per SemVer §11.4. + * + * - An empty list (a stable release) has HIGHER precedence than any prerelease. + * - Numeric identifiers compare numerically and rank lower than alphanumeric. + * - A shorter list of otherwise-equal identifiers has lower precedence. + */ +function comparePrerelease(a: readonly string[], b: readonly string[]): -1 | 0 | 1 { + if (a.length === 0 && b.length === 0) return 0; + if (a.length === 0) return 1; + if (b.length === 0) return -1; + + const length = Math.max(a.length, b.length); + for (let index = 0; index < length; index += 1) { + const left = a[index]; + const right = b[index]; + if (left === undefined) return -1; + if (right === undefined) return 1; + + const leftNumeric = NUMERIC_IDENTIFIER.test(left); + const rightNumeric = NUMERIC_IDENTIFIER.test(right); + + if (leftNumeric && rightNumeric) { + // The grammar forbids leading zeroes, so digit-count then lexicographic + // ordering is an exact numeric comparison with no precision loss. + if (left.length !== right.length) return left.length < right.length ? -1 : 1; + if (left !== right) return left < right ? -1 : 1; + continue; + } + + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; + if (left !== right) return left < right ? -1 : 1; + } + + return 0; +} + +/** + * Compare `a` and `b` by SemVer precedence: `-1` when `a < b`, `0` when equal, + * `1` when `a > b`. Build metadata is ignored (spec §10). + */ +export function compareSemver(a: SemVer, b: SemVer): -1 | 0 | 1 { + if (a.major !== b.major) return a.major < b.major ? -1 : 1; + if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1; + if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1; + return comparePrerelease(a.prerelease, b.prerelease); +} + +/** Whether `candidate` has strictly higher precedence than `current`. */ +export function isNewer(candidate: SemVer, current: SemVer): boolean { + return compareSemver(candidate, current) === 1; +} + +/** + * The release channel implied by a prerelease version — the first prerelease + * identifier, when it is a plausible npm dist-tag name. + * + * `0.2.0-alpha.1` → `"alpha"`; `1.4.0-beta` → `"beta"`. Returns `null` for a + * stable release, for a purely numeric prerelease (`1.0.0-1`), and for anything + * that does not match {@link CHANNEL_PATTERN}. + */ +export function releaseChannel(version: SemVer): string | null { + const first = version.prerelease[0]; + if (first === undefined) return null; + const candidate = first.toLowerCase(); + return CHANNEL_PATTERN.test(candidate) ? candidate : null; +} diff --git a/src/tools/status.test.ts b/src/tools/status.test.ts index af4feca..67599a1 100644 --- a/src/tools/status.test.ts +++ b/src/tools/status.test.ts @@ -17,6 +17,8 @@ vi.mock("../state.js", () => ({ readState: vi.fn(() => ({})) })); import * as bootstrap from "../bootstrap.js"; import { statusTool } from "../tools/status.js"; +import * as updateCheck from "../update-check.js"; +import { PACKAGE_VERSION } from "../version.js"; beforeEach(() => { vi.clearAllMocks(); @@ -87,3 +89,77 @@ describe("status_get", () => { expect(result.content[0].text).toContain("not installed"); }); }); + +/** + * SEC-008 update awareness. `status_get` is the "what am I running / is there a + * newer build?" surface, so the version row must appear on EVERY branch — even + * the az-missing error branch, which is exactly when a user files a bug report. + * These assertions read already-resolved in-memory state; they never trigger a + * network call. + */ +describe("status_get: server version and update state", () => { + beforeEach(() => { + updateCheck.__testing.reset(); + vi.mocked(bootstrap.assertAzCli).mockResolvedValue(undefined); + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue({ + tenantId: "tenant-123", + username: "dev@contoso.com", + }); + }); + + it("always reports the running server version", async () => { + const result = await statusTool.handler({}); + expect(result.content[0].text).toContain("**Server version**"); + expect(result.content[0].text).toContain(PACKAGE_VERSION); + }); + + it("reports the version even when az is missing (bug-report path)", async () => { + vi.mocked(bootstrap.assertAzCli).mockRejectedValue(new Error("Azure CLI ('az') is not installed.")); + + const result = await statusTool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("**Server version**"); + expect(result.content[0].text).toContain(PACKAGE_VERSION); + expect(result.content[0].text).toContain("**Update check**"); + }); + + it("reports the version when az is installed but not signed in", async () => { + vi.mocked(bootstrap.getSignedInIdentity).mockResolvedValue(null); + + const result = await statusTool.handler({}); + + expect(result.content[0].text).toContain("**Server version**"); + expect(result.content[0].text).toContain("**Update check**"); + }); + + it("renders 'in progress' while the check is still pending", async () => { + const result = await statusTool.handler({}); + expect(result.content[0].text).toMatch(/\*\*Update check\*\*\s*\|\s*in progress/); + }); + + it("renders the skip reason when the check is disabled", async () => { + process.env.SPE_NO_UPDATE_CHECK = "1"; + try { + updateCheck.startUpdateCheck({ enabled: true }); + await updateCheck.__testing.settle(); + const result = await statusTool.handler({}); + expect(result.content[0].text).toMatch(/\*\*Update check\*\*\s*\|\s*disabled/); + expect(result.content[0].text).toContain("env-spe-no-update-check"); + } finally { + delete process.env.SPE_NO_UPDATE_CHECK; + } + }); + + it("renders the disabled state for the --no-update-check flag", async () => { + updateCheck.startUpdateCheck({ enabled: false }); + await updateCheck.__testing.settle(); + + const result = await statusTool.handler({}); + + expect(result.content[0].text).toMatch(/\*\*Update check\*\*\s*\|\s*disabled/); + expect(result.content[0].text).toContain("cli-flag"); + // A disabled check must never advertise an update. + expect(result.content[0].text).not.toContain("available —"); + }); +}); diff --git a/src/tools/status.ts b/src/tools/status.ts index 3763164..a056d43 100644 --- a/src/tools/status.ts +++ b/src/tools/status.ts @@ -15,6 +15,52 @@ import { assertAzCli, getSignedInIdentity } from "../bootstrap.js"; import { readState } from "../state.js"; import type { McpTool } from "../types.js"; +import { getUpdateStatus } from "../update-check.js"; + +/** + * Render the server-version and update-awareness (SEC-008) rows shared by every + * `status_get` table. Reads already-resolved in-memory state plus the local + * cache file only: it never waits on, or triggers, a network call. + * + * The extra rows exist for privacy transparency: a user can see exactly what is + * cached locally, when it was last refreshed, which registry would be + * contacted, and where to delete the file — without any egress. + */ +function versionRows(): string { + const status = getUpdateStatus(); + let updateCell: string; + switch (status.state) { + case "disabled": + updateCell = `disabled${status.skipReason ? ` (${status.skipReason})` : ""}`; + break; + case "pending": + updateCell = "in progress"; + break; + case "up-to-date": + updateCell = "✅ up to date"; + break; + case "update-available": + updateCell = `⬆️ ${status.latestVersion ?? "newer version"} available — \`${status.updateAvailable?.command ?? ""}\``; + break; + default: + updateCell = "— unavailable (registry not reachable)"; + break; + } + + let rows = `| **Server version** | \`${status.currentVersion}\` |\n| **Update check** | ${updateCell} |\n`; + if (status.latestVersion) { + rows += `| **Latest known version** | \`${status.latestVersion}\` (cached locally) |\n`; + } + rows += `| **Last update check** | ${status.lastCheckedAt ?? "never"} |\n`; + if (status.registry) { + rows += `| **Update registry** | \`${status.registry}\` (third party, outside the M365/Azure boundary) |\n`; + } + if (status.cacheFile) { + rows += `| **Update cache file** | \`${status.cacheFile}\` |\n`; + } + rows += "| **Opt out of update check** | `--no-update-check` or `SPE_MCP_UPDATE_CHECK=false` |\n"; + return rows; +} export const statusTool: McpTool = { name: "status_get", @@ -34,7 +80,15 @@ export const statusTool: McpTool = { } catch (error) { const msg = error instanceof Error ? error.message : "Unknown error"; return { - content: [{ type: "text" as const, text: `## SPE Status\n\n⛔ ${msg}` }], + content: [ + { + type: "text" as const, + text: + `## SPE Status\n\n⛔ ${msg}\n\n` + + "| Property | Value |\n|----------|-------|\n" + + versionRows(), + }, + ], isError: true, }; } @@ -50,7 +104,9 @@ export const statusTool: McpTool = { "## SPE Status\n\n" + "| Property | Value |\n|----------|-------|\n" + "| **Azure CLI** | ✅ installed |\n" + - "| **Signed in** | ❌ not signed in |\n\n" + + "| **Signed in** | ❌ not signed in |\n" + + versionRows() + + "\n" + "> Run `az login --allow-no-subscriptions` to sign in, then try again.", }, ], @@ -67,7 +123,9 @@ export const statusTool: McpTool = { `| **Tenant** | \`${identity.tenantId}\` |\n` + `| **Owning app** | ${state.appId ? `\`${state.appId}\`${state.appDisplayName ? ` (${state.appDisplayName})` : ""}` : "— not provisioned yet"} |\n` + `| **Container type** | ${state.containerTypeId ? `\`${state.containerTypeId}\`${state.containerTypeName ? ` (${state.containerTypeName})` : ""}` : "— not provisioned yet"} |\n` + - `| **Container** | ${state.containerId ? `\`${state.containerId}\`${state.containerName ? ` (${state.containerName})` : ""}` : "— not created yet"} |\n\n` + + `| **Container** | ${state.containerId ? `\`${state.containerId}\`${state.containerName ? ` (${state.containerName})` : ""}` : "— not created yet"} |\n` + + versionRows() + + "\n" + (state.containerTypeId ? "> Provisioning in progress — resources above are saved and reused on re-runs." : hasOwningApp diff --git a/src/types.ts b/src/types.ts index a0c3898..452089f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,14 @@ export interface ServerConfig { * to see the profile list and description. */ tools?: string; + /** + * Update awareness (SEC-008). When `false`, the server never contacts the npm + * registry to see whether a newer build has been published — no network call, + * no cache read, no cache write. Defaults to enabled; set to `false` by the + * `--no-update-check` flag, and independently overridden by the + * `SPE_NO_UPDATE_CHECK` / `NO_UPDATE_NOTIFIER` environment variables. + */ + updateCheck?: boolean; } // ─── Auth Config ───────────────────────────────────────────────────────────── diff --git a/src/update-check.test.ts b/src/update-check.test.ts new file mode 100644 index 0000000..62b3a8f --- /dev/null +++ b/src/update-check.test.ts @@ -0,0 +1,1130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tests for the npm update-awareness check (SEC-008). + * + * The check is a best-effort courtesy: it must never throw, never block, never + * authenticate, and never run when the user or the environment has opted out. + * These tests therefore assert as much about what the feature *does not* do + * (no network, no cache writes, no notices) as about what it does. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + COLLECTION_NOTICE, + DEFAULT_REGISTRY, + CHECK_TTL_MS, + FAILURE_BACKOFF_MS, + MAX_RESPONSE_BYTES, + REQUEST_TIMEOUT_MS, + __testing, + getUpdateStatus, + removeUpdateCache, + startUpdateCheck, + takePendingUpdateNotice, +} from "./update-check.js"; +import { parseSemver, releaseChannel } from "./semver.js"; +import { PACKAGE_NAME, PACKAGE_VERSION } from "./version.js"; +import { + getUpdateCacheFile, + setDataDirOverride, + __testing as pathsTesting, +} from "./paths.js"; + +// --------------------------------------------------------------------------- +// Fixtures derived from the real package version so a future release bump +// (alpha -> beta -> stable) cannot silently invalidate these expectations. +// --------------------------------------------------------------------------- + +const CURRENT = parseSemver(PACKAGE_VERSION); +if (!CURRENT) throw new Error(`package.json version is not valid SemVer: ${PACKAGE_VERSION}`); + +const CHANNEL = releaseChannel(CURRENT); +const NEWER_STABLE = `${CURRENT.major + 1}.0.0`; +const NEWER_CHANNEL = CHANNEL ? `${CURRENT.major + 1}.0.0-${CHANNEL}.1` : null; +/** What the check should settle on: the user's own channel, else stable. */ +const EXPECTED_LATEST = NEWER_CHANNEL ?? NEWER_STABLE; + +/** A registry payload offering a newer build on both `latest` and the channel. */ +function tagsFixture(): Record { + const tags: Record = { latest: NEWER_STABLE }; + if (CHANNEL && NEWER_CHANNEL) tags[CHANNEL] = NEWER_CHANNEL; + return tags; +} + +function packument(tags: Record): string { + return JSON.stringify({ name: PACKAGE_NAME, "dist-tags": tags }); +} + +/** Every environment variable this module reads. */ +const ENV_KEYS = [ + "SPE_MCP_UPDATE_CHECK", + "SPE_NO_UPDATE_CHECK", + "SPE_MCP_COLLECT_TELEMETRY", + "NO_UPDATE_NOTIFIER", + "CI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "TF_BUILD", + "BUILD_BUILDID", + "SPE_NPM_REGISTRY", + "SPE_DATA_DIR", +] as const; + +let savedEnv: Record = {}; +let dataDir: string; +let fetchMock: ReturnType; + +beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + + dataDir = mkdtempSync(join(tmpdir(), "spe-mcp-update-")); + setDataDirOverride(dataDir); + + __testing.reset(); + // Default posture for flow tests: behave like a real npm install. + __testing.setInstalled(true); + + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + __testing.reset(); + pathsTesting.reset(); + rmSync(dataDir, { recursive: true, force: true }); + for (const key of ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +/** Respond with a real `Response` so the streaming/cap path is exercised. */ +function respondWith(body: string, init?: ResponseInit): void { + fetchMock.mockResolvedValue(new Response(body, { status: 200, ...init })); +} + +function cacheExists(): boolean { + return existsSync(getUpdateCacheFile()); +} + +function readCacheFile(): Record { + return JSON.parse(readFileSync(getUpdateCacheFile(), "utf8")) as Record; +} + +// --------------------------------------------------------------------------- +// Skip reasons +// --------------------------------------------------------------------------- + +describe("resolveSkipReason", () => { + it("allows the check when nothing opts out and the build is installed", () => { + expect(__testing.resolveSkipReason({})).toBeNull(); + }); + + it("reports the CLI flag when enabled is false", () => { + expect(__testing.resolveSkipReason({ enabled: false })).toBe("cli-flag"); + }); + + it("treats an omitted enabled option as opt-in", () => { + expect(__testing.resolveSkipReason({ enabled: undefined })).toBeNull(); + }); + + it("honors SPE_NO_UPDATE_CHECK", () => { + process.env.SPE_NO_UPDATE_CHECK = "1"; + expect(__testing.resolveSkipReason({})).toBe("env-spe-no-update-check"); + }); + + it("honors NO_UPDATE_NOTIFIER (the community convention)", () => { + process.env.NO_UPDATE_NOTIFIER = "true"; + expect(__testing.resolveSkipReason({})).toBe("env-no-update-notifier"); + }); + + it.each([ + ["CI"], + ["CONTINUOUS_INTEGRATION"], + ["GITHUB_ACTIONS"], + ["TF_BUILD"], + ["BUILD_BUILDID"], + ])("detects CI via %s", (name) => { + process.env[name] = "1"; + expect(__testing.resolveSkipReason({})).toBe("ci"); + }); + + it("skips source checkouts so contributors are never told to npm install", () => { + __testing.setInstalled(false); + expect(__testing.resolveSkipReason({})).toBe("source-install"); + }); + + it.each([["0"], ["false"], ["no"], ["off"], [""], [" "], ["FALSE"], ["Off"]])( + "treats %s as not opting out", + (value) => { + process.env.SPE_NO_UPDATE_CHECK = value; + expect(__testing.resolveSkipReason({})).toBeNull(); + }, + ); + + it.each([["1"], ["true"], ["yes"], ["anything"]])("treats %s as opting out", (value) => { + process.env.SPE_NO_UPDATE_CHECK = value; + expect(__testing.resolveSkipReason({})).toBe("env-spe-no-update-check"); + }); + + it("prefers the most specific reason when several apply", () => { + process.env.SPE_NO_UPDATE_CHECK = "1"; + process.env.NO_UPDATE_NOTIFIER = "1"; + process.env.CI = "1"; + __testing.setInstalled(false); + expect(__testing.resolveSkipReason({ enabled: false })).toBe("cli-flag"); + expect(__testing.resolveSkipReason({})).toBe("env-spe-no-update-check"); + }); +}); + +// --------------------------------------------------------------------------- +// Registry resolution +// --------------------------------------------------------------------------- + +describe("resolveRegistry", () => { + it("defaults to the public npm registry", () => { + expect(__testing.resolveRegistry()).toBe(DEFAULT_REGISTRY); + expect(DEFAULT_REGISTRY.startsWith("https://")).toBe(true); + }); + + it("accepts an HTTPS override", () => { + process.env.SPE_NPM_REGISTRY = "https://registry.contoso.example"; + expect(__testing.resolveRegistry()).toBe("https://registry.contoso.example"); + }); + + it("strips trailing slashes so the URL join stays canonical", () => { + process.env.SPE_NPM_REGISTRY = "https://registry.contoso.example/npm///"; + expect(__testing.resolveRegistry()).toBe("https://registry.contoso.example/npm"); + }); + + it("falls back to the default when the override is blank", () => { + process.env.SPE_NPM_REGISTRY = " "; + expect(__testing.resolveRegistry()).toBe(DEFAULT_REGISTRY); + }); + + it.each([ + ["plain HTTP", "http://registry.npmjs.org"], + ["a non-web scheme", "file:///etc/passwd"], + ["embedded credentials", "https://user:pass@registry.contoso.example"], + ["a username only", "https://user@registry.contoso.example"], + ["a query string", "https://registry.contoso.example?token=abc"], + ["a fragment", "https://registry.contoso.example#token"], + ["a non-URL", "not a url"], + ["a bare host", "registry.contoso.example"], + ])("rejects %s", (_label, value) => { + process.env.SPE_NPM_REGISTRY = value; + expect(__testing.resolveRegistry()).toBeNull(); + }); + + it("rejects an absurdly long override", () => { + process.env.SPE_NPM_REGISTRY = `https://example.com/${"a".repeat(600)}`; + expect(__testing.resolveRegistry()).toBeNull(); + }); +}); + +describe("buildPackumentUrl", () => { + it("escapes the scope separator the way npm does", () => { + const url = __testing.buildPackumentUrl(DEFAULT_REGISTRY); + expect(url).toBe(`${DEFAULT_REGISTRY}/${PACKAGE_NAME.replace("/", "%2f")}`); + expect(url).not.toContain("@microsoft/spe-mcp"); + }); + + it("produces a URL the platform can parse", () => { + const url = __testing.buildPackumentUrl(DEFAULT_REGISTRY); + expect(url).not.toBeNull(); + expect(() => new URL(url as string)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Hostile payload handling +// --------------------------------------------------------------------------- + +describe("extractDistTags", () => { + it("keeps well-formed tags", () => { + expect(__testing.extractDistTags(packument({ latest: "1.2.3", next: "2.0.0-rc.1" }))).toEqual({ + latest: "1.2.3", + next: "2.0.0-rc.1", + }); + }); + + it.each([ + ["malformed JSON", "{not json"], + ["a JSON array", "[]"], + ["a JSON string", '"hello"'], + ["JSON null", "null"], + ["a number", "42"], + ["an object with no dist-tags", '{"name":"x"}'], + ["dist-tags as an array", '{"dist-tags":[]}'], + ["dist-tags as null", '{"dist-tags":null}'], + ["dist-tags as a string", '{"dist-tags":"latest"}'], + ])("returns no tags for %s", (_label, raw) => { + expect(__testing.extractDistTags(raw)).toEqual({}); + }); + + it("drops prototype-pollution keys", () => { + const raw = '{"dist-tags":{"__proto__":"9.9.9","constructor":"9.9.9","prototype":"9.9.9","latest":"1.0.0"}}'; + const tags = __testing.extractDistTags(raw); + expect(tags).toEqual({ latest: "1.0.0" }); + expect(({} as Record)["polluted"]).toBeUndefined(); + expect(Object.getPrototypeOf(tags)).toBeNull(); + }); + + it("drops non-string and non-SemVer values", () => { + const raw = JSON.stringify({ + "dist-tags": { + latest: "1.0.0", + numeric: 3, + nested: { version: "2.0.0" }, + listy: ["2.0.0"], + nully: null, + loose: "v2.0.0", + ranged: "^2.0.0", + partial: "2.0", + empty: "", + }, + }); + expect(__testing.extractDistTags(raw)).toEqual({ latest: "1.0.0" }); + }); + + it("drops over-long tag names and values", () => { + const raw = JSON.stringify({ + "dist-tags": { + latest: "1.0.0", + ["t".repeat(65)]: "1.0.0", + long: `1.0.0-${"a".repeat(300)}`, + }, + }); + expect(__testing.extractDistTags(raw)).toEqual({ latest: "1.0.0" }); + }); + + it("drops an empty tag name", () => { + expect(__testing.extractDistTags('{"dist-tags":{"":"1.0.0","latest":"1.0.0"}}')).toEqual({ + latest: "1.0.0", + }); + }); +}); + +// --------------------------------------------------------------------------- +// Response size cap +// --------------------------------------------------------------------------- + +describe("readCappedText", () => { + it("reads a small body", async () => { + const response = new Response("hello"); + await expect(__testing.readCappedText(response, MAX_RESPONSE_BYTES)).resolves.toBe("hello"); + }); + + it("rejects a body whose declared content-length exceeds the cap", async () => { + const response = new Response("{}", { + headers: { "content-length": String(MAX_RESPONSE_BYTES + 1) }, + }); + await expect(__testing.readCappedText(response, MAX_RESPONSE_BYTES)).resolves.toBeNull(); + }); + + it("rejects a streamed body that exceeds the cap despite a truthful-looking header", async () => { + const response = new Response("x".repeat(MAX_RESPONSE_BYTES + 1_024)); + await expect(__testing.readCappedText(response, MAX_RESPONSE_BYTES)).resolves.toBeNull(); + }); + + it("accepts a body exactly at the cap", async () => { + const body = "y".repeat(MAX_RESPONSE_BYTES); + const text = await __testing.readCappedText(new Response(body), MAX_RESPONSE_BYTES); + expect(text).toHaveLength(MAX_RESPONSE_BYTES); + }); + + it("falls back to text() when the response exposes no readable stream", async () => { + const fake = { + headers: new Headers(), + body: null, + text: async () => "fallback", + } as unknown as Response; + await expect(__testing.readCappedText(fake, MAX_RESPONSE_BYTES)).resolves.toBe("fallback"); + }); + + it("rejects an oversize body on the text() fallback path", async () => { + const fake = { + headers: new Headers(), + body: null, + text: async () => "z".repeat(MAX_RESPONSE_BYTES + 1), + } as unknown as Response; + await expect(__testing.readCappedText(fake, MAX_RESPONSE_BYTES)).resolves.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Cache freshness +// --------------------------------------------------------------------------- + +describe("cache", () => { + const base = { + version: 1 as const, + checkedAt: 1_000_000, + currentVersion: PACKAGE_VERSION, + registry: DEFAULT_REGISTRY, + outcome: "success" as const, + notifiedFor: [] as string[], + }; + + it("round-trips through secure-fs", () => { + __testing.writeCache({ ...base, latest: "9.9.9", notifiedFor: ["9.9.9"] }); + expect(cacheExists()).toBe(true); + const read = __testing.readCache(); + expect(read?.latest).toBe("9.9.9"); + expect(read?.notifiedFor).toEqual(["9.9.9"]); + }); + + it("returns null when no cache file exists", () => { + expect(__testing.readCache()).toBeNull(); + }); + + it.each([ + ["malformed JSON", "{oops"], + ["an array", "[]"], + ["a wrong schema version", '{"version":2}'], + ["a missing timestamp", '{"version":1,"currentVersion":"1.0.0","registry":"r","outcome":"success"}'], + ["a bogus outcome", '{"version":1,"checkedAt":1,"currentVersion":"1.0.0","registry":"r","outcome":"maybe"}'], + ])("ignores a cache with %s", (_label, raw) => { + writeFileSync(getUpdateCacheFile(), raw, "utf8"); + expect(__testing.readCache()).toBeNull(); + }); + + it("discards non-string notifiedFor entries", () => { + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ ...base, notifiedFor: ["1.0.0", 5, null, { a: 1 }] }), + "utf8", + ); + expect(__testing.readCache()?.notifiedFor).toEqual(["1.0.0"]); + }); + + it("treats a recent success as fresh", () => { + expect(__testing.isCacheFresh(base, DEFAULT_REGISTRY, base.checkedAt + 1_000)).toBe(true); + }); + + it("expires a success at the TTL boundary", () => { + expect(__testing.isCacheFresh(base, DEFAULT_REGISTRY, base.checkedAt + CHECK_TTL_MS)).toBe(false); + expect(__testing.isCacheFresh(base, DEFAULT_REGISTRY, base.checkedAt + CHECK_TTL_MS - 1)).toBe(true); + }); + + it("expires a failure at the shorter backoff boundary", () => { + const failure = { ...base, outcome: "failure" as const }; + expect(FAILURE_BACKOFF_MS).toBeLessThan(CHECK_TTL_MS); + expect(__testing.isCacheFresh(failure, DEFAULT_REGISTRY, base.checkedAt + FAILURE_BACKOFF_MS)).toBe(false); + expect(__testing.isCacheFresh(failure, DEFAULT_REGISTRY, base.checkedAt + FAILURE_BACKOFF_MS - 1)).toBe(true); + }); + + it("rejects a cache written by a different build", () => { + expect( + __testing.isCacheFresh({ ...base, currentVersion: "0.0.1" }, DEFAULT_REGISTRY, base.checkedAt), + ).toBe(false); + }); + + it("rejects a cache written against a different registry", () => { + expect(__testing.isCacheFresh(base, "https://other.example", base.checkedAt)).toBe(false); + }); + + it("rejects a cache from the future (clock skew or tampering)", () => { + expect(__testing.isCacheFresh(base, DEFAULT_REGISTRY, base.checkedAt - 1)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Notice rendering +// --------------------------------------------------------------------------- + +describe("renderNotice", () => { + it("names the channel and the exact install command", () => { + const text = __testing.renderNotice({ + package: PACKAGE_NAME, + current: "1.0.0-alpha.1", + latest: "1.0.0-alpha.2", + channel: "alpha", + command: `npm install -g ${PACKAGE_NAME}@alpha`, + }); + expect(text).toContain("1.0.0-alpha.1 -> 1.0.0-alpha.2"); + expect(text).toContain("(alpha channel)"); + expect(text).toContain(`npm install -g ${PACKAGE_NAME}@alpha`); + expect(text).toContain("--no-update-check"); + expect(text).not.toContain("Latest stable release"); + }); + + it("calls out a separate stable target when one exists", () => { + const text = __testing.renderNotice({ + package: PACKAGE_NAME, + current: "1.0.0-alpha.1", + latest: "1.0.0-alpha.2", + channel: "alpha", + stable: "2.0.0", + command: `npm install -g ${PACKAGE_NAME}@alpha`, + }); + expect(text).toContain("Latest stable release: 2.0.0"); + }); + + it("omits the channel clause for a stable build", () => { + const text = __testing.renderNotice({ + package: PACKAGE_NAME, + current: "1.0.0", + latest: "1.1.0", + channel: null, + command: `npm install -g ${PACKAGE_NAME}@latest`, + }); + expect(text).not.toContain("channel)"); + expect(text).toContain(`npm install -g ${PACKAGE_NAME}@latest`); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end flow +// --------------------------------------------------------------------------- + +describe("runUpdateCheck", () => { + it("surfaces a newer release exactly once and records it", async () => { + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const status = getUpdateStatus(); + expect(status.state).toBe("update-available"); + expect(status.latestVersion).toBe(EXPECTED_LATEST); + expect(status.currentVersion).toBe(PACKAGE_VERSION); + expect(status.updateAvailable?.command).toContain(`npm install -g ${PACKAGE_NAME}@`); + + const notice = takePendingUpdateNotice(); + expect(notice?.text).toContain(EXPECTED_LATEST); + expect(notice?.updateAvailable.latest).toBe(EXPECTED_LATEST); + // Take-and-clear: a second consumer must not see it again. + expect(takePendingUpdateNotice()).toBeNull(); + + expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + }); + + it("requests the abbreviated packument with the product user agent and no credentials", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url.startsWith(`${DEFAULT_REGISTRY}/`)).toBe(true); + expect(init.method).toBe("GET"); + expect(init.redirect).toBe("error"); + expect(init.signal).toBeInstanceOf(AbortSignal); + const headers = init.headers as Record; + expect(headers["accept"]).toBe("application/vnd.npm.install-v1+json"); + expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization"); + expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("cookie"); + expect(REQUEST_TIMEOUT_MS).toBe(2_000); + }); + + it("does not notify twice for the same target across restarts", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + expect(takePendingUpdateNotice()).not.toBeNull(); + + // Simulate a restart a week later: state is reset, cache survives. + __testing.reset(); + __testing.setInstalled(true); + const stale = readCacheFile(); + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ ...stale, checkedAt: Date.now() - CHECK_TTL_MS * 7 }), + "utf8", + ); + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().state).toBe("update-available"); + expect(takePendingUpdateNotice()).toBeNull(); + }); + + it("reports up-to-date when the registry offers nothing newer", async () => { + respondWith(packument({ latest: PACKAGE_VERSION, ...(CHANNEL ? { [CHANNEL]: PACKAGE_VERSION } : {}) })); + + await __testing.runUpdateCheck({}); + + expect(getUpdateStatus().state).toBe("up-to-date"); + expect(takePendingUpdateNotice()).toBeNull(); + expect(readCacheFile()["outcome"]).toBe("success"); + }); + + it("ignores older published versions", async () => { + respondWith(packument({ latest: "0.0.1" })); + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().state).toBe("up-to-date"); + expect(takePendingUpdateNotice()).toBeNull(); + }); + + it("reuses a fresh success cache without touching the network", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + expect(fetchMock).toHaveBeenCalledTimes(1); + + __testing.reset(); + __testing.setInstalled(true); + await __testing.runUpdateCheck({}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getUpdateStatus().state).toBe("update-available"); + expect(takePendingUpdateNotice()).toBeNull(); + }); + + it("backs off after a failure instead of retrying every start", async () => { + fetchMock.mockRejectedValue(new Error("offline")); + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().state).toBe("unavailable"); + expect(readCacheFile()["outcome"]).toBe("failure"); + + __testing.reset(); + __testing.setInstalled(true); + await __testing.runUpdateCheck({}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getUpdateStatus().state).toBe("unavailable"); + }); + + it.each([ + [ + "a network error", + () => { + fetchMock.mockRejectedValue(new Error("getaddrinfo ENOTFOUND")); + }, + ], + [ + "a timeout", + () => { + fetchMock.mockRejectedValue( + Object.assign(new Error("The operation was aborted"), { name: "TimeoutError" }), + ); + }, + ], + [ + "a 500 response", + () => { + fetchMock.mockResolvedValue(new Response("boom", { status: 500 })); + }, + ], + [ + "a 404 response", + () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 404 })); + }, + ], + [ + "an oversize body", + () => { + fetchMock.mockResolvedValue(new Response("x".repeat(MAX_RESPONSE_BYTES + 512))); + }, + ], + [ + "a body that lies about its length", + () => { + fetchMock.mockResolvedValue( + new Response("{}", { headers: { "content-length": String(MAX_RESPONSE_BYTES * 4) } }), + ); + }, + ], + ])("degrades silently on %s", async (_label, arrange) => { + arrange(); + await expect(__testing.runUpdateCheck({})).resolves.toBeUndefined(); + expect(getUpdateStatus().state).toBe("unavailable"); + expect(takePendingUpdateNotice()).toBeNull(); + expect(readCacheFile()["outcome"]).toBe("failure"); + }); + + it.each([ + ["garbage that is not JSON", "404"], + ["JSON with no dist-tags", '{"name":"x"}'], + ["dist-tags full of junk", '{"dist-tags":{"latest":"not-a-version","next":{"a":1}}}'], + ["prototype pollution attempts", '{"dist-tags":{"__proto__":"999.0.0"}}'], + ])("treats %s as no update rather than an error", async (_label, body) => { + respondWith(body); + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().state).toBe("up-to-date"); + expect(takePendingUpdateNotice()).toBeNull(); + }); + + it.each([ + ["the CLI flag", () => ({ enabled: false }) as const, "cli-flag"], + [ + "SPE_NO_UPDATE_CHECK", + () => { + process.env.SPE_NO_UPDATE_CHECK = "1"; + return {}; + }, + "env-spe-no-update-check", + ], + [ + "NO_UPDATE_NOTIFIER", + () => { + process.env.NO_UPDATE_NOTIFIER = "1"; + return {}; + }, + "env-no-update-notifier", + ], + [ + "CI detection", + () => { + process.env.GITHUB_ACTIONS = "true"; + return {}; + }, + "ci", + ], + [ + "a source checkout", + () => { + __testing.setInstalled(false); + return {}; + }, + "source-install", + ], + ])("makes no network call and writes no cache when disabled by %s", async (_label, arrange, reason) => { + const options = arrange(); + + await __testing.runUpdateCheck(options); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(cacheExists()).toBe(false); + expect(takePendingUpdateNotice()).toBeNull(); + const status = getUpdateStatus(); + expect(status.enabled).toBe(false); + expect(status.state).toBe("disabled"); + expect(status.skipReason).toBe(reason); + }); + + it("refuses a non-HTTPS registry override without calling out", async () => { + process.env.SPE_NPM_REGISTRY = "http://registry.npmjs.org"; + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(cacheExists()).toBe(false); + expect(getUpdateStatus().skipReason).toBe("invalid-registry"); + }); + + it("honors a valid HTTPS registry override", async () => { + process.env.SPE_NPM_REGISTRY = "https://registry.contoso.example/npm/"; + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url.startsWith("https://registry.contoso.example/npm/")).toBe(true); + expect(readCacheFile()["registry"]).toBe("https://registry.contoso.example/npm"); + }); + + it("re-probes when the registry changes even inside the TTL", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + expect(fetchMock).toHaveBeenCalledTimes(1); + + __testing.reset(); + __testing.setInstalled(true); + process.env.SPE_NPM_REGISTRY = "https://registry.contoso.example"; + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("never throws even when the cache directory is unusable", async () => { + respondWith(packument(tagsFixture())); + // Point the data dir at a path whose parent is a file: every write fails. + const blocker = join(dataDir, "blocker"); + writeFileSync(blocker, "not a directory", "utf8"); + setDataDirOverride(join(blocker, "nested")); + + await expect(__testing.runUpdateCheck({})).resolves.toBeUndefined(); + expect(getUpdateStatus().state).toBe("update-available"); + }); +}); + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +describe("startUpdateCheck", () => { + it("returns immediately and completes in the background", async () => { + respondWith(packument(tagsFixture())); + + const returned = startUpdateCheck({ enabled: true }); + expect(returned).toBeUndefined(); + // Not yet observable: the caller was never blocked. + expect(getUpdateStatus().state).toBe("pending"); + + await __testing.settle(); + expect(getUpdateStatus().state).toBe("update-available"); + }); + + it("defaults to enabled when no options are passed", async () => { + respondWith(packument(tagsFixture())); + startUpdateCheck(); + await __testing.settle(); + expect(getUpdateStatus().enabled).toBe(true); + }); + + it("swallows a synchronous fetch explosion", async () => { + fetchMock.mockImplementation(() => { + throw new Error("boom"); + }); + startUpdateCheck({ enabled: true }); + await expect(__testing.settle()).resolves.toBeUndefined(); + expect(getUpdateStatus().state).toBe("unavailable"); + }); + + it("reports a disabled check without ever reaching the network", async () => { + startUpdateCheck({ enabled: false }); + await __testing.settle(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(getUpdateStatus()).toMatchObject({ + enabled: false, + state: "disabled", + skipReason: "cli-flag", + currentVersion: PACKAGE_VERSION, + }); + }); +}); + +describe("getUpdateStatus", () => { + it("starts pending with the running version and no leaked internals", () => { + const status = getUpdateStatus(); + expect(status).toMatchObject({ enabled: true, state: "pending", currentVersion: PACKAGE_VERSION }); + expect(status.latestVersion).toBeUndefined(); + expect(status.updateAvailable).toBeUndefined(); + }); + + it("exposes an ISO timestamp once a check completes", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + const status = getUpdateStatus(); + expect(status.lastCheckedAt).toBeDefined(); + expect(new Date(status.lastCheckedAt as string).toISOString()).toBe(status.lastCheckedAt); + }); +}); + +// --------------------------------------------------------------------------- +// Privacy review deltas +// +// The privacy pre-review for this feature is NOT signed off. These tests pin +// the commitments the implementation makes so a later change cannot quietly +// widen what is disclosed to the third-party npm registry. +// --------------------------------------------------------------------------- + +/** The `init` object handed to `fetch` on the single request. */ +function requestInit(): RequestInit { + expect(fetchMock).toHaveBeenCalledTimes(1); + return fetchMock.mock.calls[0]![1] as RequestInit; +} + +/** The request headers, lower-cased, as a plain object. */ +function requestHeaders(): Record { + const raw = (requestInit().headers ?? {}) as Record; + const out: Record = {}; + for (const [key, value] of Object.entries(raw)) out[key.toLowerCase()] = value; + return out; +} + +describe("privacy: request shape", () => { + it("issues exactly one GET to the exact package path with no query or fragment", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = fetchMock.mock.calls[0]![0] as string; + expect(url).toBe(`${DEFAULT_REGISTRY}/${PACKAGE_NAME.replace("/", "%2f")}`); + expect(url).toBe("https://registry.npmjs.org/@microsoft%2fspe-mcp"); + + const parsed = new URL(url); + expect(parsed.protocol).toBe("https:"); + expect(parsed.search).toBe(""); + expect(parsed.hash).toBe(""); + expect(parsed.username).toBe(""); + expect(parsed.password).toBe(""); + expect(requestInit().method).toBe("GET"); + }); + + it("sends only accept and the static product User-Agent", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + const headers = requestHeaders(); + expect(Object.keys(headers).sort()).toEqual(["accept", "user-agent"]); + expect(headers.accept).toBe("application/vnd.npm.install-v1+json"); + expect(headers["user-agent"]).toBe(`spe-mcp-server/${PACKAGE_VERSION}`); + }); + + it("sends no credential, cookie, or identifying header", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + const headers = requestHeaders(); + for (const banned of [ + "authorization", + "cookie", + "proxy-authorization", + "x-api-key", + "x-ms-client-request-id", + "x-correlation-id", + "client-request-id", + "x-anchormailbox", + ]) { + expect(headers[banned]).toBeUndefined(); + } + expect(requestInit().credentials).toBe("omit"); + expect(requestInit().body).toBeUndefined(); + }); + + it("carries no account, tenant, machine, session, or install identifier anywhere", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + const serialized = JSON.stringify({ + url: fetchMock.mock.calls[0]![0], + init: requestInit(), + headers: requestHeaders(), + cache: readCacheFile(), + }); + // No GUID-shaped value may appear in anything we send or persist. + expect(serialized).not.toMatch( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, + ); + for (const banned of [ + "tenant", + "objectId", + "upn", + "userId", + "machineId", + "installId", + "sessionId", + "deviceId", + "hostname", + "correlation", + ]) { + expect(serialized.toLowerCase()).not.toContain(banned.toLowerCase()); + } + }); + + it("refuses to follow redirects", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + expect(requestInit().redirect).toBe("error"); + }); + + it("rejects a response that was redirected or served by another host", async () => { + // A runtime that followed a hop anyway must still be rejected. + const redirected = new Response(packument(tagsFixture()), { status: 200 }); + Object.defineProperty(redirected, "redirected", { value: true }); + fetchMock.mockResolvedValue(redirected); + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().state).toBe("unavailable"); + + // A same-status response served from a foreign origin must be rejected too. + __testing.reset(); + __testing.setInstalled(true); + rmSync(getUpdateCacheFile(), { force: true }); + const foreign = new Response(packument(tagsFixture()), { status: 200 }); + Object.defineProperty(foreign, "url", { value: "https://evil.example/@microsoft%2fspe-mcp" }); + fetchMock.mockResolvedValue(foreign); + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().state).toBe("unavailable"); + }); +}); + +describe("privacy: zero-network opt-outs", () => { + it.each([ + ["SPE_MCP_UPDATE_CHECK", "false", "env-spe-mcp-update-check"], + ["SPE_MCP_UPDATE_CHECK", "0", "env-spe-mcp-update-check"], + ["SPE_MCP_UPDATE_CHECK", "off", "env-spe-mcp-update-check"], + ["SPE_NO_UPDATE_CHECK", "1", "env-spe-no-update-check"], + ["NO_UPDATE_NOTIFIER", "1", "env-no-update-notifier"], + ["SPE_MCP_COLLECT_TELEMETRY", "false", "env-telemetry-disabled"], + ["SPE_MCP_COLLECT_TELEMETRY", "0", "env-telemetry-disabled"], + ["CI", "1", "ci"], + ])("%s=%s suppresses the check entirely (%s)", async (name, value, reason) => { + process.env[name] = value; + expect(__testing.resolveSkipReason({})).toBe(reason); + + await __testing.runUpdateCheck({}); + expect(fetchMock).not.toHaveBeenCalled(); + expect(cacheExists()).toBe(false); + expect(takePendingUpdateNotice()).toBeNull(); + expect(__testing.collectionNoticeEmitted()).toBe(false); + expect(getUpdateStatus()).toMatchObject({ enabled: false, state: "disabled", skipReason: reason }); + }); + + it("treats SPE_MCP_UPDATE_CHECK=true as leaving the check enabled", () => { + process.env.SPE_MCP_UPDATE_CHECK = "true"; + expect(__testing.resolveSkipReason({})).toBeNull(); + }); + + it("prefers the CLI flag over every environment control", () => { + process.env.SPE_MCP_UPDATE_CHECK = "false"; + process.env.SPE_MCP_COLLECT_TELEMETRY = "false"; + expect(__testing.resolveSkipReason({ enabled: false })).toBe("cli-flag"); + }); + + it("ranks the preferred public control above the back-compat alias", () => { + process.env.SPE_MCP_UPDATE_CHECK = "false"; + process.env.SPE_NO_UPDATE_CHECK = "1"; + expect(__testing.resolveSkipReason({})).toBe("env-spe-mcp-update-check"); + }); + + it.each([["1"], ["true"], ["yes"], [""], [" "]])( + "does not treat SPE_MCP_UPDATE_CHECK=%s as an opt-out", + (value) => { + process.env.SPE_MCP_UPDATE_CHECK = value; + expect(__testing.envFlagDisabled("SPE_MCP_UPDATE_CHECK")).toBe(false); + }, + ); +}); + +describe("privacy: first-run collection notice", () => { + let stderr: ReturnType; + let events: string[]; + + beforeEach(() => { + events = []; + stderr = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + if (String(args[0]).includes(COLLECTION_NOTICE)) events.push("notice"); + }); + fetchMock.mockImplementation(() => { + events.push("fetch"); + return Promise.resolve(new Response(packument(tagsFixture()), { status: 200 })); + }); + }); + + afterEach(() => { + stderr.mockRestore(); + }); + + it("names the endpoint, the boundary, the retention, and the opt-out", () => { + expect(COLLECTION_NOTICE).toContain("npm registry"); + expect(COLLECTION_NOTICE).toContain("OUTSIDE the Microsoft 365 / Azure"); + expect(COLLECTION_NOTICE).toContain("IP address"); + expect(COLLECTION_NOTICE).toContain("User-Agent"); + expect(COLLECTION_NOTICE).toContain("cached locally until you delete it"); + expect(COLLECTION_NOTICE).toContain("Nothing is downloaded, installed, or updated automatically"); + expect(COLLECTION_NOTICE).toContain("--no-update-check"); + expect(COLLECTION_NOTICE).toContain("SPE_MCP_UPDATE_CHECK=false"); + }); + + it("emits on stderr strictly before the first request", async () => { + await __testing.runUpdateCheck({}); + expect(events).toEqual(["notice", "fetch"]); + expect(__testing.collectionNoticeEmitted()).toBe(true); + }); + + it("emits at most once per process", async () => { + await __testing.runUpdateCheck({}); + rmSync(getUpdateCacheFile(), { force: true }); + await __testing.runUpdateCheck({}); + expect(events.filter((e) => e === "notice")).toHaveLength(1); + expect(events.filter((e) => e === "fetch")).toHaveLength(2); + }); + + it("never emits when the check is opted out", async () => { + process.env.SPE_MCP_UPDATE_CHECK = "false"; + await __testing.runUpdateCheck({}); + expect(events).toEqual([]); + expect(__testing.collectionNoticeEmitted()).toBe(false); + }); + + it("never emits when a fresh cache answers without a request", async () => { + __testing.writeCache({ + version: 1, + checkedAt: Date.now(), + currentVersion: PACKAGE_VERSION, + registry: DEFAULT_REGISTRY, + outcome: "success", + notifiedFor: [], + }); + await __testing.runUpdateCheck({}); + expect(events).toEqual([]); + expect(__testing.collectionNoticeEmitted()).toBe(false); + }); +}); + +describe("privacy: cache retention and deletion", () => { + it("removes the cache file, mirroring logout", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + expect(cacheExists()).toBe(true); + + removeUpdateCache(); + expect(cacheExists()).toBe(false); + }); + + it("is a safe no-op when no cache exists and never throws", () => { + expect(cacheExists()).toBe(false); + expect(() => { + removeUpdateCache(); + removeUpdateCache(); + }).not.toThrow(); + }); + + it("persists no identifier and only the fields the feature needs", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + expect(Object.keys(readCacheFile()).sort()).toEqual( + expect.arrayContaining(["checkedAt", "currentVersion", "outcome", "registry", "version"]), + ); + for (const key of Object.keys(readCacheFile())) { + expect(key.toLowerCase()).not.toContain("id"); + expect(key.toLowerCase()).not.toContain("user"); + expect(key.toLowerCase()).not.toContain("tenant"); + } + }); +}); + +describe("privacy: status_get reporting is offline", () => { + it("reports the cache location and opt-out without any network call", () => { + const status = getUpdateStatus(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(status.cacheFile).toBe(getUpdateCacheFile()); + expect(status.registry).toBe(DEFAULT_REGISTRY); + expect(status.currentVersion).toBe(PACKAGE_VERSION); + }); + + it("surfaces the locally cached result in a fresh process", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + __testing.reset(); // simulate a restart: in-memory state gone, cache on disk + + const status = getUpdateStatus(); + expect(fetchMock).toHaveBeenCalledTimes(1); // still only the original check + expect(status.latestVersion).toBe(EXPECTED_LATEST); + expect(status.lastCheckedAt).toBeDefined(); + expect(status.cacheFile).toBe(getUpdateCacheFile()); + }); + + it("still reports when opted out, without reading the network", () => { + process.env.SPE_MCP_UPDATE_CHECK = "false"; + startUpdateCheck({}); + const status = getUpdateStatus(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(status).toMatchObject({ enabled: false, state: "disabled" }); + expect(status.cacheFile).toBe(getUpdateCacheFile()); + }); + + it("tolerates a missing or corrupt cache file", () => { + writeFileSync(getUpdateCacheFile(), "{not json", "utf8"); + expect(() => getUpdateStatus()).not.toThrow(); + expect(getUpdateStatus().latestVersion).toBeUndefined(); + + rmSync(getUpdateCacheFile(), { force: true }); + expect(getUpdateStatus().cacheFile).toBe(getUpdateCacheFile()); + }); +}); diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 0000000..af1710e --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,914 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * SEC-008 — npm update awareness. + * + * Tells the user, once, when a newer build of this server has been published to + * the public npm registry. It NEVER updates anything: there is no auto-update, + * no install, no child process, and no write outside the server's own data dir. + * + * SHAPE OF THE FEATURE + * - After the MCP transport is connected, a fire-and-forget probe reads the + * package's `dist-tags` from the registry. Nothing awaits it; if it is slow, + * fails, or never finishes, the server behaves exactly as it does today. + * - When the installed build is a prerelease (e.g. `0.2.0-alpha.1`) the matching + * channel dist-tag (`alpha`) is the primary target, and a newer STABLE release + * is reported separately so prerelease users learn when GA lands. + * - The result is surfaced by appending one short notice to exactly ONE + * subsequent successful tool result (plus `structuredContent.updateAvailable`), + * and by `status_get`. It is never printed to stdout — stdout is the JSON-RPC + * channel and writing to it corrupts the protocol. + * + * WHERE THE DATA GOES (disclosure) + * - The only endpoint contacted is the public npm registry, by default + * `https://registry.npmjs.org`. That is a THIRD-PARTY service operated by npm, + * Inc. / GitHub and is **outside the Microsoft 365 and Azure compliance + * boundary**. It is not an M365 service and is not covered by the M365 data + * residency, EUDB, or tenant-data commitments. + * - Making the connection at all inherently discloses to that third party the + * **client IP address**, the TLS/HTTP metadata of the connection, the requested + * **package name** (in the URL path), and — unless telemetry is opted out — the + * static product **`User-Agent`** string. Nothing else is sent. + * - No account, tenant, subscription, container, machine, install, session, or + * content data is sent. There is no install GUID and no correlation identifier + * of any kind, in the request or in the cache. + * - Before the FIRST network request of a process, a one-time collection notice + * ({@link COLLECTION_NOTICE}) is written to stderr naming the endpoint, its + * boundary status, and the opt-out. Skipped/cached runs make no request and so + * emit no notice. + * - The result is cached on the local disk only. It is **retained until deleted** + * — by `spe-mcp logout`, by removing the data dir, or by deleting the file + * reported by `status_get`. There is no server-side record and no retention + * schedule to expire it for you. + * + * SECURITY CONTROLS + * - Unauthenticated GET only. No credential, cookie, `.npmrc`, or auth header is + * read or sent, and no npm CLI or other child process is spawned. + * - Exact package path only: no query string and no fragment, on the request or + * on an `SPE_NPM_REGISTRY` override. + * - Registry origin is pinned to `https://registry.npmjs.org` unless overridden + * by `SPE_NPM_REGISTRY`, which MUST be `https:` and MUST NOT embed credentials. + * - `redirect: "error"` plus an explicit post-response host check, so the pinned + * origin cannot be bounced to another host. + * - Hard {@link REQUEST_TIMEOUT_MS} timeout and a streamed + * {@link MAX_RESPONSE_BYTES} cap, so a hostile or hung registry cannot stall + * or exhaust the process. + * - The response is parsed defensively: prototype-polluting keys are dropped, + * over-long keys/values are dropped, and every version is validated by the + * strict SemVer parser before it is compared or shown. + * - Results are cached under the server data dir with the same owner-only + * secure-fs primitives as the token cache (0700 dir / 0600 file, no symlink + * traversal), with a TTL plus a shorter failure backoff so a broken network is + * not re-probed on every start. + * + * KNOWN LIMITATION (accepted tradeoff, not a sign-off) + * - Node's built-in `fetch` does not honour `HTTP_PROXY` / `HTTPS_PROXY` / + * `NO_PROXY`. Adding proxy support would require a new runtime dependency, + * which this package deliberately does not take. On a proxy-only network the + * probe simply fails closed (silent no-op) rather than bypassing the proxy. + * Operators who must not egress at all should turn the check off outright. + * + * ZERO-NETWORK OPT-OUTS — each skips the check entirely (no request, no notice, + * no cache read, no cache write): `--no-update-check`, `SPE_MCP_UPDATE_CHECK=false`, + * `SPE_NO_UPDATE_CHECK=1` (back-compatible alias), `NO_UPDATE_NOTIFIER=1`, + * `SPE_MCP_COLLECT_TELEMETRY=false`, any CI marker, and source checkouts. + */ + +import { existsSync, unlinkSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { createLogger } from "./logger.js"; +import { getDataDir, getUpdateCacheFile } from "./paths.js"; +import { ensureSecureDir, readSecureFile, writeSecureFile } from "./secure-fs.js"; +import { isNewer, parseSemver, releaseChannel, type SemVer } from "./semver.js"; +import { applyProductUserAgent } from "./user-agent.js"; +import { PACKAGE_NAME, PACKAGE_VERSION } from "./version.js"; + +/** Default public registry. Overridable only by an explicit HTTPS URL. */ +export const DEFAULT_REGISTRY = "https://registry.npmjs.org"; + +/** Hard ceiling on the probe. Deliberately short: this is best-effort garnish. */ +export const REQUEST_TIMEOUT_MS = 2_000; + +/** Streamed response cap. The abbreviated packument is a few KB at most. */ +export const MAX_RESPONSE_BYTES = 64 * 1024; + +/** How long a successful probe is reused before re-checking. */ +export const CHECK_TTL_MS = 24 * 60 * 60 * 1000; + +/** How long a failed probe is remembered before retrying (shorter than the TTL). */ +export const FAILURE_BACKOFF_MS = 6 * 60 * 60 * 1000; + +/** Cap on remembered "already told the user about this version" entries. */ +const MAX_NOTIFIED_ENTRIES = 10; + +/** Cap on a dist-tag name we are willing to look at. */ +const MAX_TAG_NAME_LENGTH = 64; + +/** Cap on a dist-tag value we are willing to look at. */ +const MAX_TAG_VALUE_LENGTH = 256; + +/** Cap on an accepted `SPE_NPM_REGISTRY` value. */ +const MAX_REGISTRY_LENGTH = 512; + +/** Keys that must never be copied out of untrusted JSON. */ +const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** npm package-name grammar, used to prove the name is URL-path safe. */ +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; + +/** Values that mean "off" for any of the opt-out / CI environment variables. */ +const FALSY_ENV_VALUES = new Set(["0", "false", "no", "off"]); + +/** Environment variables whose presence means "this is automation, stay quiet". */ +const CI_ENV_VARS = [ + "CI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "TF_BUILD", + "BUILD_BUILDID", +] as const; + +const logger = createLogger("Update"); + +/** + * One-time stderr disclosure emitted immediately BEFORE the first network + * request of a process. Exported so docs and tests assert the exact wording. + * + * It must name the endpoint, say plainly that the endpoint sits outside the + * Microsoft 365 / Azure compliance boundary, say what the connection discloses, + * say that nothing is installed, and name the opt-out. + */ +export const COLLECTION_NOTICE = [ + "Update check: contacting the public npm registry to see whether a newer", + `version of ${PACKAGE_NAME} has been published.`, + "The npm registry is a third-party service OUTSIDE the Microsoft 365 / Azure", + "compliance boundary. The request is unauthenticated and sends no account,", + "tenant, machine, session, or content data — but the connection itself", + "discloses your IP address, the package name, and the product User-Agent to", + "that third party. The result is cached locally until you delete it.", + "Nothing is downloaded, installed, or updated automatically.", + "Turn this off with --no-update-check or SPE_MCP_UPDATE_CHECK=false.", +].join(" "); + +/** Why the check did not run. */ +export type UpdateSkipReason = + | "cli-flag" + | "env-spe-mcp-update-check" + | "env-spe-no-update-check" + | "env-no-update-notifier" + | "env-telemetry-disabled" + | "ci" + | "source-install" + | "invalid-registry" + | "invalid-package"; + +/** Machine-readable description of an available update. */ +export interface UpdateAvailable { + /** npm package name this build was published as. */ + readonly package: string; + /** The version currently running. */ + readonly current: string; + /** The newest version on the user's own channel (or stable, when on stable). */ + readonly latest: string; + /** Release channel of the running build (`alpha`, `beta`, …), or `null`. */ + readonly channel: string | null; + /** Newest STABLE release, when it is also newer than the running build. */ + readonly stable?: string; + /** The exact command a user can run to update. Informational only. */ + readonly command: string; +} + +/** A ready-to-append notice plus its structured twin. */ +export interface UpdateNotice { + readonly text: string; + readonly updateAvailable: UpdateAvailable; +} + +/** Lifecycle of the check, as reported by `status_get`. */ +export type UpdateCheckState = + | "disabled" + | "pending" + | "up-to-date" + | "update-available" + | "unavailable"; + +/** Snapshot of the check for diagnostics surfaces. Never triggers a network call. */ +export interface UpdateCheckStatus { + readonly enabled: boolean; + readonly state: UpdateCheckState; + readonly currentVersion: string; + readonly skipReason?: UpdateSkipReason; + readonly latestVersion?: string; + readonly channel?: string; + readonly lastCheckedAt?: string; + readonly updateAvailable?: UpdateAvailable; + /** Absolute path of the local cache file, so a user can inspect or delete it. */ + readonly cacheFile?: string; + /** Registry origin that would be (or was) contacted. */ + readonly registry?: string; +} + +/** Options accepted by {@link startUpdateCheck}. */ +export interface StartUpdateCheckOptions { + /** `false` when the user passed `--no-update-check`. Defaults to enabled. */ + readonly enabled?: boolean; +} + +/** On-disk cache shape. `version` guards against future format changes. */ +interface UpdateCache { + version: 1; + checkedAt: number; + currentVersion: string; + registry: string; + outcome: "success" | "failure"; + latest?: string; + channelTag?: string; + channelVersion?: string; + notifiedFor: string[]; +} + +// --------------------------------------------------------------------------- +// Process-local state +// --------------------------------------------------------------------------- + +let pendingNotice: UpdateNotice | null = null; +let status: UpdateCheckStatus = { enabled: true, state: "pending", currentVersion: PACKAGE_VERSION }; +let inFlight: Promise | null = null; +/** Test-only override for "am I running from an installed package?". */ +let installedOverride: boolean | null = null; +/** Guards the one-time pre-network collection notice for this process. */ +let collectionNoticeEmitted = false; + +// --------------------------------------------------------------------------- +// Environment / eligibility +// --------------------------------------------------------------------------- + +/** + * Whether an environment variable is set to something meaning "yes". + * + * Unset, empty, `0`, `false`, `no`, and `off` all mean "no"; anything else means + * "yes". Shared by the opt-outs and the CI detectors so they behave identically. + */ +function envFlagEnabled(name: string): boolean { + const raw = process.env[name]; + if (raw === undefined) return false; + const value = raw.trim().toLowerCase(); + if (value === "") return false; + return !FALSY_ENV_VALUES.has(value); +} + +/** + * Whether an environment variable is explicitly set to something meaning "no". + * + * Distinct from `!envFlagEnabled(name)`: an unset variable is NOT a "no" here, + * so `SPE_MCP_UPDATE_CHECK` and `SPE_MCP_COLLECT_TELEMETRY` only suppress the + * check when the operator deliberately turned them off. + */ +function envFlagDisabled(name: string): boolean { + const raw = process.env[name]; + if (raw === undefined) return false; + const value = raw.trim().toLowerCase(); + if (value === "") return false; + return FALSY_ENV_VALUES.has(value); +} + +/** + * Whether this process is running from an installed npm package rather than a + * source checkout. Contributors running the server out of the repo should never + * be told to `npm install` over their working tree. + */ +function isInstalledFromRegistry(): boolean { + if (installedOverride !== null) return installedOverride; + try { + return fileURLToPath(import.meta.url).split(/[\\/]+/).includes("node_modules"); + } catch { + return false; + } +} + +/** + * The reason the check must not run, or `null` when it may proceed. + * + * Every reason here is a ZERO-NETWORK suppression: no request is made, the + * collection notice is not emitted, and the cache is neither read nor written. + * + * Order matters: explicit user intent (flag, then the preferred env control, + * then its back-compatible aliases, then the telemetry master switch) beats + * environment inference (CI, source checkout), so the reported reason is the + * most specific one. + */ +function resolveSkipReason(options: StartUpdateCheckOptions): UpdateSkipReason | null { + if (options.enabled === false) return "cli-flag"; + // Preferred public control: SPE_MCP_UPDATE_CHECK=false. + if (envFlagDisabled("SPE_MCP_UPDATE_CHECK")) return "env-spe-mcp-update-check"; + // Back-compatible alias kept so existing deployments keep working. + if (envFlagEnabled("SPE_NO_UPDATE_CHECK")) return "env-spe-no-update-check"; + // Community convention shared with update-notifier and friends. + if (envFlagEnabled("NO_UPDATE_NOTIFIER")) return "env-no-update-notifier"; + // Telemetry master switch: opting out of telemetry opts out of egress here too. + if (envFlagDisabled("SPE_MCP_COLLECT_TELEMETRY")) return "env-telemetry-disabled"; + if (CI_ENV_VARS.some((name) => envFlagEnabled(name))) return "ci"; + if (!isInstalledFromRegistry()) return "source-install"; + return null; +} + +/** + * The registry origin (plus optional base path) to query, or `null` when the + * configured override is not something we are willing to talk to. + * + * Only `https:` is accepted, embedded credentials are rejected outright (this + * request must never carry authentication), and query/fragment are rejected so + * the override cannot smuggle parameters onto the lookup. + */ +function resolveRegistry(): string | null { + const raw = process.env.SPE_NPM_REGISTRY?.trim(); + if (!raw) return DEFAULT_REGISTRY; + if (raw.length > MAX_REGISTRY_LENGTH) return null; + + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + + if (url.protocol !== "https:") return null; + if (url.username !== "" || url.password !== "") return null; + if (url.search !== "" || url.hash !== "") return null; + + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} + +/** + * The packument URL for this package on `registry`, or `null` when the package + * name is not the plain npm grammar (belt-and-braces: the name comes from our + * own package.json, but it is interpolated into a URL path). + */ +function buildPackumentUrl(registry: string): string | null { + if (!PACKAGE_NAME_PATTERN.test(PACKAGE_NAME)) return null; + // npm's canonical scoped form keeps the leading `@` and escapes only the `/`. + return `${registry}/${PACKAGE_NAME.replace(/\//g, "%2f")}`; +} + +// --------------------------------------------------------------------------- +// Registry access +// --------------------------------------------------------------------------- + +/** + * Read at most `cap` bytes of `response`, returning `null` when the body is + * larger. The declared `content-length` is checked first as a cheap reject; the + * stream is then read incrementally so a lying header cannot get past the cap. + */ +async function readCappedText(response: Response, cap: number): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > cap) return null; + + const body = response.body; + if (!body || typeof body.getReader !== "function") { + const text = await response.text(); + return Buffer.byteLength(text, "utf8") > cap ? null : text; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > cap) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} + +/** + * Extract a trustworthy `name -> version` map from a raw packument body. + * + * Everything here treats the input as hostile: the JSON may be any shape, keys + * may be prototype pollution attempts, and values may be enormous or not + * versions at all. Anything that is not a short tag name mapped to a strictly + * valid SemVer string is dropped silently. + */ +function extractDistTags(raw: string): Record { + const result: Record = Object.create(null) as Record; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return result; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return result; + + const tags = (parsed as Record)["dist-tags"]; + if (typeof tags !== "object" || tags === null || Array.isArray(tags)) return result; + + for (const [name, value] of Object.entries(tags as Record)) { + if (FORBIDDEN_KEYS.has(name)) continue; + if (name.length === 0 || name.length > MAX_TAG_NAME_LENGTH) continue; + if (typeof value !== "string" || value.length > MAX_TAG_VALUE_LENGTH) continue; + if (parseSemver(value) === null) continue; + result[name] = value; + } + + return result; +} + +/** + * Emit the {@link COLLECTION_NOTICE} to stderr, at most once per process. + * + * Called immediately before the first network request, so a run that is opted + * out, skipped, or served from cache never makes a request AND never emits the + * notice. stderr only — stdout is the JSON-RPC channel. + */ +function emitCollectionNotice(): void { + if (collectionNoticeEmitted) return; + collectionNoticeEmitted = true; + logger.log(COLLECTION_NOTICE); +} + +/** + * Fetch the package's dist-tags. Returns `null` on any failure — offline, + * timeout, redirect, non-2xx, oversize body, or unparseable payload — so every + * failure mode collapses to the same silent no-op. + * + * Privacy/security shape of the request, asserted by tests: + * - exactly one GET, to the exact package path, with no query and no fragment; + * - no `authorization`, no `cookie`, and no identifier of any kind; + * - `credentials: "omit"` and `redirect: "error"`, plus an explicit check that + * the response did not come from a different host than the one we dialled; + * - the only headers are `accept` and — unless telemetry is opted out — the + * static product `User-Agent`. + */ +async function fetchDistTags(url: string): Promise | null> { + let expectedHost: string; + try { + expectedHost = new URL(url).host; + } catch { + return null; + } + + // Last thing before any egress: tell the user what is about to happen. + emitCollectionNotice(); + + try { + const response = await fetch(url, { + method: "GET", + redirect: "error", + credentials: "omit", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: applyProductUserAgent({ + // The abbreviated packument is orders of magnitude smaller than the full one. + accept: "application/vnd.npm.install-v1+json", + }), + }); + if (!response.ok) return null; + + // Defence in depth behind `redirect: "error"`: if anything (a proxy, a + // future runtime change) still followed a hop, refuse a foreign host. + if (response.redirected) return null; + if (typeof response.url === "string" && response.url !== "") { + try { + if (new URL(response.url).host !== expectedHost) return null; + } catch { + return null; + } + } + + const body = await readCappedText(response, MAX_RESPONSE_BYTES); + if (body === null) return null; + + return extractDistTags(body); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +/** Read the cache, tolerating absence, corruption, and secure-fs rejections. */ +function readCache(): UpdateCache | null { + let raw: string | null; + try { + raw = readSecureFile(getUpdateCacheFile()); + } catch { + return null; + } + if (raw === null) return null; + + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const candidate = parsed as Partial; + if (candidate.version !== 1) return null; + if (typeof candidate.checkedAt !== "number" || !Number.isFinite(candidate.checkedAt)) return null; + if (typeof candidate.currentVersion !== "string") return null; + if (typeof candidate.registry !== "string") return null; + if (candidate.outcome !== "success" && candidate.outcome !== "failure") return null; + + const notified = Array.isArray(candidate.notifiedFor) + ? candidate.notifiedFor.filter((entry): entry is string => typeof entry === "string") + : []; + + return { + version: 1, + checkedAt: candidate.checkedAt, + currentVersion: candidate.currentVersion, + registry: candidate.registry, + outcome: candidate.outcome, + latest: typeof candidate.latest === "string" ? candidate.latest : undefined, + channelTag: typeof candidate.channelTag === "string" ? candidate.channelTag : undefined, + channelVersion: + typeof candidate.channelVersion === "string" ? candidate.channelVersion : undefined, + notifiedFor: notified.slice(-MAX_NOTIFIED_ENTRIES), + }; + } catch { + return null; + } +} + +/** Persist the cache with owner-only permissions. Failures are non-fatal. */ +function writeCache(cache: UpdateCache): void { + try { + ensureSecureDir(getDataDir()); + writeSecureFile( + getUpdateCacheFile(), + JSON.stringify({ ...cache, notifiedFor: cache.notifiedFor.slice(-MAX_NOTIFIED_ENTRIES) }, null, 2), + ); + } catch { + // Best-effort: an unwritable cache only costs an extra probe next time. + } +} + +/** Whether a cache entry is still authoritative for `registry` and this build. */ +function isCacheFresh(cache: UpdateCache, registry: string, now: number): boolean { + if (cache.currentVersion !== PACKAGE_VERSION) return false; + if (cache.registry !== registry) return false; + const age = now - cache.checkedAt; + if (age < 0) return false; + return age < (cache.outcome === "success" ? CHECK_TTL_MS : FAILURE_BACKOFF_MS); +} + +/** + * Delete the local update-check cache. + * + * Wired into `spe-mcp logout` (and `auth --reset`) so signing out clears every + * file this server wrote under the data directory, not just the token cache. + * Best-effort and never throws: a missing or unremovable file is not an error. + */ +export function removeUpdateCache(): void { + try { + const file = getUpdateCacheFile(); + if (!existsSync(file)) return; + unlinkSync(file); + logger.debug("Removed update-check cache."); + } catch { + // Best-effort: leaving the file behind is not a failure worth surfacing. + } +} + +// --------------------------------------------------------------------------- +// Notice construction +// --------------------------------------------------------------------------- + +/** Pick the newest version carried by `tag`, when it beats `current`. */ +function newerTagVersion( + tags: Record, + tag: string | null, + current: SemVer, +): string | undefined { + if (!tag) return undefined; + const raw = Object.prototype.hasOwnProperty.call(tags, tag) ? tags[tag] : undefined; + if (raw === undefined) return undefined; + const parsed = parseSemver(raw); + if (!parsed) return undefined; + return isNewer(parsed, current) ? parsed.raw : undefined; +} + +/** + * Reconstruct the version a cached result would have pointed at, using exactly + * the same channel-first rule as a live check. Purely local: this reads the + * cache file only and never touches the network. + */ +function cachedTargetVersion(cache: UpdateCache): string | undefined { + const tags: Record = {}; + if (cache.latest) tags["latest"] = cache.latest; + if (cache.channelTag && cache.channelVersion) tags[cache.channelTag] = cache.channelVersion; + + const current = parseSemver(PACKAGE_VERSION); + if (current) { + const channel = releaseChannel(current); + const target = + newerTagVersion(tags, channel, current) ?? newerTagVersion(tags, "latest", current); + if (target) return target; + } + // Nothing newer is known: still report the newest version we saw, so the + // status table can show what the cache actually holds. + return cache.channelVersion ?? cache.latest; +} + +/** Render the single notice appended to a tool result. */ +function renderNotice(update: UpdateAvailable): string { + const lines = [ + `Update available: ${update.package} ${update.current} -> ${update.latest}` + + `${update.channel ? ` (${update.channel} channel)` : ""}. Update with: ${update.command}`, + ]; + if (update.stable) { + lines.push( + `Latest stable release: ${update.stable} (npm install -g ${update.package}@latest).`, + ); + } + lines.push( + "Nothing was downloaded or installed; this is a notification only. " + + "Disable this check with --no-update-check or SPE_MCP_UPDATE_CHECK=false.", + ); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +/** + * The whole check. Never throws: every failure path leaves the server exactly as + * it would have been had the feature not existed. + */ +async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { + try { + const skipReason = resolveSkipReason(options); + if (skipReason) { + status = { + enabled: false, + state: "disabled", + currentVersion: PACKAGE_VERSION, + skipReason, + }; + logger.debug(`Update check skipped (${skipReason})`); + return; + } + + const current = parseSemver(PACKAGE_VERSION); + if (!current) { + status = { + enabled: false, + state: "disabled", + currentVersion: PACKAGE_VERSION, + skipReason: "invalid-package", + }; + return; + } + + const registry = resolveRegistry(); + if (!registry) { + status = { + enabled: false, + state: "disabled", + currentVersion: PACKAGE_VERSION, + skipReason: "invalid-registry", + }; + logger.debug("Update check skipped (SPE_NPM_REGISTRY is not a credential-free HTTPS URL)"); + return; + } + + const url = buildPackumentUrl(registry); + if (!url) { + status = { + enabled: false, + state: "disabled", + currentVersion: PACKAGE_VERSION, + skipReason: "invalid-package", + }; + return; + } + + const now = Date.now(); + const cached = readCache(); + const fresh = cached && isCacheFresh(cached, registry, now) ? cached : null; + + let tags: Record | null; + let checkedAt: number; + let notifiedFor: string[]; + + if (fresh) { + // Within TTL/backoff: reuse the prior outcome, no network at all. + checkedAt = fresh.checkedAt; + notifiedFor = [...fresh.notifiedFor]; + tags = + fresh.outcome === "success" + ? buildTagsFromCache(fresh) + : null; + if (fresh.outcome === "failure") { + status = { + enabled: true, + state: "unavailable", + currentVersion: PACKAGE_VERSION, + lastCheckedAt: new Date(checkedAt).toISOString(), + }; + return; + } + } else { + notifiedFor = cached ? [...cached.notifiedFor] : []; + checkedAt = now; + tags = await fetchDistTags(url); + if (tags === null) { + writeCache({ + version: 1, + checkedAt: now, + currentVersion: PACKAGE_VERSION, + registry, + outcome: "failure", + notifiedFor, + }); + status = { + enabled: true, + state: "unavailable", + currentVersion: PACKAGE_VERSION, + lastCheckedAt: new Date(now).toISOString(), + }; + return; + } + } + + const channel = releaseChannel(current); + const channelVersion = newerTagVersion(tags ?? {}, channel, current); + const stableVersion = newerTagVersion(tags ?? {}, "latest", current); + + // Prefer the user's own channel; fall back to stable (the only target when + // the running build is itself a stable release). + const latest = channelVersion ?? stableVersion; + const lastCheckedAt = new Date(checkedAt).toISOString(); + + if (!fresh) { + writeCache({ + version: 1, + checkedAt, + currentVersion: PACKAGE_VERSION, + registry, + outcome: "success", + latest: tags?.["latest"], + channelTag: channel ?? undefined, + channelVersion: channel ? tags?.[channel] : undefined, + notifiedFor, + }); + } + + if (!latest) { + status = { + enabled: true, + state: "up-to-date", + currentVersion: PACKAGE_VERSION, + channel: channel ?? undefined, + lastCheckedAt, + }; + return; + } + + const update: UpdateAvailable = { + package: PACKAGE_NAME, + current: PACKAGE_VERSION, + latest, + channel, + // Only call out stable separately when it is a different, additional target. + ...(stableVersion && stableVersion !== latest ? { stable: stableVersion } : {}), + command: `npm install -g ${PACKAGE_NAME}@${channelVersion && channel ? channel : "latest"}`, + }; + + status = { + enabled: true, + state: "update-available", + currentVersion: PACKAGE_VERSION, + latestVersion: latest, + channel: channel ?? undefined, + lastCheckedAt, + updateAvailable: update, + }; + + // Per-target suppression: each newer version is announced exactly once, even + // across restarts, so the notice never becomes background noise. + if (notifiedFor.includes(latest)) return; + + pendingNotice = { text: renderNotice(update), updateAvailable: update }; + writeCache({ + version: 1, + checkedAt, + currentVersion: PACKAGE_VERSION, + registry, + outcome: "success", + latest: tags?.["latest"], + channelTag: channel ?? undefined, + channelVersion: channel ? tags?.[channel] : undefined, + notifiedFor: [...notifiedFor, latest], + }); + logger.debug(`Update available: ${PACKAGE_VERSION} -> ${latest}`); + } catch { + // A best-effort courtesy must never affect the server. + } +} + +/** Rebuild the tag map from a fresh success cache entry (no network). */ +function buildTagsFromCache(cache: UpdateCache): Record { + const tags: Record = Object.create(null) as Record; + if (cache.latest) tags["latest"] = cache.latest; + if (cache.channelTag && cache.channelVersion) tags[cache.channelTag] = cache.channelVersion; + return tags; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Kick off the update check. Returns immediately and is never awaited by the + * server; the work happens on a detached promise that swallows all errors. + */ +export function startUpdateCheck(options: StartUpdateCheckOptions = {}): void { + try { + inFlight = runUpdateCheck(options).catch(() => undefined); + } catch { + // Unreachable in practice; belt-and-braces so start-up can never fail here. + } +} + +/** + * Take the pending notice, clearing it. + * + * Returning-and-clearing is what makes the notice appear on exactly one tool + * result: whichever call happens to run after the probe resolves gets it, and + * every later call sees `null`. + */ +export function takePendingUpdateNotice(): UpdateNotice | null { + const notice = pendingNotice; + pendingNotice = null; + return notice; +} + +/** + * Current check state, for `status_get` and diagnostics. + * + * Read-only and strictly local: this never makes a network request. When the + * live status has nothing to say (opted out, or a fresh process that has not + * probed yet) the locally cached result is surfaced instead, so a user can + * always see what is stored, when it was stored, and where the file lives. + */ +export function getUpdateStatus(): UpdateCheckStatus { + const cacheFile = getUpdateCacheFile(); + const cache = readCache(); + + return { + ...status, + cacheFile, + registry: status.registry ?? cache?.registry ?? resolveRegistry() ?? DEFAULT_REGISTRY, + latestVersion: status.latestVersion ?? (cache ? cachedTargetVersion(cache) : undefined), + lastCheckedAt: + status.lastCheckedAt ?? + (cache ? new Date(cache.checkedAt).toISOString() : undefined), + }; +} + +/** + * Test-only hooks. Not part of the public API. + */ +export const __testing = { + /** Reset all process-local state between tests. */ + reset(): void { + pendingNotice = null; + inFlight = null; + installedOverride = null; + collectionNoticeEmitted = false; + status = { enabled: true, state: "pending", currentVersion: PACKAGE_VERSION }; + }, + /** Pretend the process is (or is not) running from an installed package. */ + setInstalled(value: boolean | null): void { + installedOverride = value; + }, + /** Await the in-flight check so assertions are deterministic. */ + async settle(): Promise { + await inFlight; + }, + /** Run the check and await it directly. */ + runUpdateCheck, + resolveSkipReason, + resolveRegistry, + buildPackumentUrl, + extractDistTags, + readCappedText, + readCache, + writeCache, + isCacheFresh, + renderNotice, + removeUpdateCache, + envFlagDisabled, + /** Whether the one-time collection notice has already been emitted. */ + collectionNoticeEmitted(): boolean { + return collectionNoticeEmitted; + }, +}; diff --git a/src/version.ts b/src/version.ts index 3811025..081b0fa 100644 --- a/src/version.ts +++ b/src/version.ts @@ -21,7 +21,19 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"); -const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { version: string }; +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { + version: string; + name: string; +}; /** The product version, sourced from `package.json`. */ export const PACKAGE_VERSION: string = packageJson.version; + +/** + * The published npm package name, sourced from `package.json`. + * + * Consumed by the update-awareness check (update-check.ts) so the registry it + * queries and the `npm install` hint it prints always name the package this + * build was actually cut from, even if the package is ever renamed. + */ +export const PACKAGE_NAME: string = packageJson.name; From fe720e04e8f9fc081f21a2f25e052c4285c20f32 Mon Sep 17 00:00:00 2001 From: grjoseph Date: Thu, 20 Aug 2026 20:20:47 -0700 Subject: [PATCH 2/9] fix(update-check): address code review findings on notice delivery and status Follow-up to the npm update-awareness change, addressing review feedback. Behaviour fixes: - Record a version as "already notified" only when the notice is actually delivered by takePendingUpdateNotice(), not when the probe resolves. The cache is re-read and merged at delivery time, so a process that exits or restarts before the notice is shown replays it instead of losing it permanently. Delivery never resurrects a cache file that logout removed. - Set the failure backoff to the same 24h TTL as a success, so the documented "at most one registry request per day" claim holds for failures too. - Guard startUpdateCheck() with an in-flight check so a second call cannot start a duplicate probe in the same process. - Create a { updateAvailable } structured twin when a tool result has no structuredContent, instead of dropping the structured signal. Defence in depth: - Build tag maps on a null-prototype object and validate cached channel tag names with the same rules applied to registry data, so hostile cache content stays inert. - getUpdateStatus() reads no registry, no cached version and no disk at all when the check is disabled, and skips the disk read entirely once this process already holds a result. Removed the unreachable registry branch. Docs: - Correct the default data directory in PRIVACY.md to ~/.spe-mcp ( %USERPROFILE%\.spe-mcp on Windows) and map the cache file permissions to SEC-003. - Update SEC-008 and the changelog for the 24h failure backoff and for the notice being persisted at delivery. - Document the remaining last-writer-wins risk on concurrent cache writes as a follow-up; shared secure-fs atomicity is deliberately unchanged here. Privacy wording, the first-run collection notice and every opt-out control are unchanged. Still notify-only: no auto-update, no new runtime dependency (6), and nothing is ever written to stdout. Tests: added coverage for delivery-time persistence across simulated process exits and restarts, the 24h failure backoff, the in-flight guard, hostile cache content, the cheap/quiet status path and the structured-twin creation. AB#3219463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 +- PRIVACY.md | 8 +- docs/SECURITY-CONTROLS.md | 2 +- src/index-update-notice.test.ts | 134 ++++++++++++++++++++ src/index.ts | 9 +- src/update-check.test.ts | 215 +++++++++++++++++++++++++++++++- src/update-check.ts | 133 ++++++++++++++------ 7 files changed, 464 insertions(+), 45 deletions(-) create mode 100644 src/index-update-notice.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c05a45..18be827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). cache-file path, and the opt-out controls — all read from disk, with **no network access**. - **Update-check cache lifecycle.** The cached result at `/update-check.json` contains **no identifier** and is retained until deleted; `spe-mcp logout` and - `spe-mcp auth --reset` now remove it alongside the cached tokens. + `spe-mcp auth --reset` now remove it alongside the cached tokens. A version is recorded + as "already notified" only when the notice is actually delivered on a tool result, so a + process that exits before any tool call replays the notice on the next run instead of + losing it. - **Boundary disclosure.** `README.md`, `PRIVACY.md`, `docs/DATA-FLOW.md`, `docs/SECURITY-CONTROLS.md`, and `docs/TROUBLESHOOTING.md` document that `registry.npmjs.org` (npm, Inc./GitHub) is the only endpoint **outside the Microsoft 365 / @@ -53,7 +56,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). correlation, or session identifier**, and omits the product `User-Agent` when `SPE_MCP_COLLECT_TELEMETRY=false`. It is bounded by a 2-second timeout and a 64 KB response cap, parsed with strict SemVer and prototype-pollution-safe key filtering, and cached - owner-only (SEC-003) with a 24-hour TTL and a failure backoff, deleted on `logout` / + owner-only (SEC-003) with a 24-hour TTL — a failed check backs off for the same 24 hours, + so at most one request per day is made either way — deleted on `logout` / `auth --reset`. `SPE_NPM_REGISTRY` values carrying credentials, a query string, or a fragment are rejected. **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it diff --git a/PRIVACY.md b/PRIVACY.md index 9a59d2e..3830d4c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -75,9 +75,11 @@ also be turned off. Specifically: *notifies* you; applying an update is always a manual `npm install` you run yourself. **Local retention.** The result is cached on your machine at - `/update-check.json` (owner-only permissions, control **SEC-008**; - `` is `%LOCALAPPDATA%\spe-mcp` on Windows or `~/.local/share/spe-mcp` elsewhere, - and is reported by `status_get`). The cache contains only the checked version strings, the + `/update-check.json`, written with the same owner-only permissions as the token + cache (0700 directory / 0600 file, control **SEC-003**; the check itself is control + **SEC-008**). `` defaults to `%USERPROFILE%\.spe-mcp` on Windows or `~/.spe-mcp` + elsewhere, can be overridden with `SPE_DATA_DIR`, and the exact path in use is reported by + `status_get`. The cache contains only the checked version strings, the registry URL, a timestamp, and which versions you have already been told about — **no identifier**. It is **retained locally until you delete it**: there is no automatic expiry of the file itself, only of its freshness. Run `spe-mcp logout` or `spe-mcp auth --reset` to diff --git a/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md index 6d390ed..05192a4 100644 --- a/docs/SECURITY-CONTROLS.md +++ b/docs/SECURITY-CONTROLS.md @@ -24,7 +24,7 @@ that maps each code to a human-readable name and a one-line description. | SEC-002 | Client-safe error messages | Tool `catch` blocks surface only sanitized, consistent messages to clients; internal detail stays in server-side logs. | | SEC-003 | Secure filesystem (owner-only) | Credential and state files (token cache, server state) are written owner-only (POSIX `0o600`; ACL-governed on Windows). | | SEC-007 | Docs endpoint validation | The Microsoft Learn MCP endpoint is resolved and validated before use to prevent redirection to an untrusted host. | -| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data) and omits the product `User-Agent` when telemetry is opted out, is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), cached owner-only via SEC-003 with a 24 h TTL and failure backoff and deleted on `logout` / `auth --reset`, announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | +| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data) and omits the product `User-Agent` when telemetry is opted out, is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), cached owner-only via SEC-003 with a 24 h TTL (a failed check backs off for the same 24 h, so at most one request per day either way) and deleted on `logout` / `auth --reset`, announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | > Adding a new safeguard? Give it the next code in its family and add a row here > so code comments and tests have a lookup. diff --git a/src/index-update-notice.test.ts b/src/index-update-notice.test.ts new file mode 100644 index 0000000..e3c4268 --- /dev/null +++ b/src/index-update-notice.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Tests for how a pending update notice is attached to a tool result (SEC-008). + * + * The notice must ride along on exactly one successful result without ever + * blocking on the network, and the machine-readable twin must survive even when + * the tool itself produced no structured content — a client that only reads + * `structuredContent` would otherwise never learn about the update. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { __testing as serverTesting } from "./index.js"; +import { __testing as updateTesting } from "./update-check.js"; +import { setDataDirOverride, __testing as pathsTesting } from "./paths.js"; +import { PACKAGE_NAME, PACKAGE_VERSION } from "./version.js"; + +const LATEST = "99.0.0"; + +function packument(): string { + return JSON.stringify({ name: PACKAGE_NAME, "dist-tags": { latest: LATEST } }); +} + +const ENV_KEYS = [ + "SPE_MCP_UPDATE_CHECK", + "SPE_NO_UPDATE_CHECK", + "SPE_MCP_COLLECT_TELEMETRY", + "NO_UPDATE_NOTIFIER", + "CI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "TF_BUILD", + "BUILD_BUILDID", + "SPE_NPM_REGISTRY", + "SPE_DATA_DIR", +] as const; + +let savedEnv: Record = {}; +let dataDir: string; + +/** Arrange a resolved "update available" state without touching the network. */ +async function primeNotice(): Promise { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(packument(), { status: 200 })), + ); + await updateTesting.runUpdateCheck({}); +} + +const TEXT = [{ type: "text" as const, text: "tool output" }]; + +beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + dataDir = mkdtempSync(join(tmpdir(), "spe-mcp-notice-")); + setDataDirOverride(dataDir); + updateTesting.reset(); + updateTesting.setInstalled(true); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + updateTesting.reset(); + pathsTesting.reset(); + rmSync(dataDir, { recursive: true, force: true }); + for (const key of ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("withUpdateNotice", () => { + it("creates a structured twin when the tool returned none", async () => { + await primeNotice(); + + const result = serverTesting.withUpdateNotice(TEXT, undefined); + + expect(result.content).toHaveLength(2); + expect(result.content[1]?.text).toContain(LATEST); + expect(result.structuredContent).toMatchObject({ + updateAvailable: { latest: LATEST, current: PACKAGE_VERSION }, + }); + }); + + it("creates a structured twin when the tool returned a non-object", async () => { + await primeNotice(); + + const result = serverTesting.withUpdateNotice(TEXT, [1, 2, 3]); + + expect(result.structuredContent).toMatchObject({ updateAvailable: { latest: LATEST } }); + }); + + it("merges into structured content the tool already produced", async () => { + await primeNotice(); + + const result = serverTesting.withUpdateNotice(TEXT, { data: { ok: true }, durationMs: 5 }); + + expect(result.structuredContent).toMatchObject({ + data: { ok: true }, + durationMs: 5, + updateAvailable: { latest: LATEST }, + }); + }); + + it("passes the result through untouched when nothing is pending", () => { + const structured = { data: { ok: true } }; + + const result = serverTesting.withUpdateNotice(TEXT, structured); + + expect(result.content).toEqual(TEXT); + expect(result.structuredContent).toBe(structured); + }); + + it("appends the notice to only one result", async () => { + await primeNotice(); + + const first = serverTesting.withUpdateNotice(TEXT, undefined); + const second = serverTesting.withUpdateNotice(TEXT, undefined); + + expect(first.content).toHaveLength(2); + expect(second.content).toEqual(TEXT); + expect(second.structuredContent).toBeUndefined(); + }); +}); diff --git a/src/index.ts b/src/index.ts index f86605f..639f285 100644 --- a/src/index.ts +++ b/src/index.ts @@ -216,6 +216,10 @@ function withDuration(structuredContent: unknown, durationMs: number): unknown { * This never waits on the network: `takePendingUpdateNotice()` only reads * already-resolved in-memory state and returns `null` when the check is * disabled, still running, failed, or found nothing newer. + * + * When the tool produced no structured content, a fresh `{ updateAvailable }` + * object is created rather than dropping the machine-readable twin: a client + * that only reads `structuredContent` would otherwise never see the update. */ function withUpdateNotice( content: { type: "text"; text: string }[], @@ -227,7 +231,7 @@ function withUpdateNotice( const merged = structuredContent && typeof structuredContent === "object" && !Array.isArray(structuredContent) ? { ...(structuredContent as Record), updateAvailable } - : structuredContent; + : { updateAvailable }; return { content: [...content, { type: "text" as const, text: notice.text }], structuredContent: merged, @@ -538,3 +542,6 @@ export async function startServer(config: ServerConfig) { } } } + +/** Test-only hooks. Not part of the public API and not used at runtime. */ +export const __testing = { withUpdateNotice }; \ No newline at end of file diff --git a/src/update-check.test.ts b/src/update-check.test.ts index 62b3a8f..f99c5af 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -420,13 +420,23 @@ describe("cache", () => { expect(__testing.isCacheFresh(base, DEFAULT_REGISTRY, base.checkedAt + CHECK_TTL_MS - 1)).toBe(true); }); - it("expires a failure at the shorter backoff boundary", () => { + it("expires a failure at the same 24h boundary as a success", () => { const failure = { ...base, outcome: "failure" as const }; - expect(FAILURE_BACKOFF_MS).toBeLessThan(CHECK_TTL_MS); + // The failure backoff MUST equal the TTL: every doc promises "at most one + // request per day", which a shorter backoff would silently break. + expect(FAILURE_BACKOFF_MS).toBe(CHECK_TTL_MS); + expect(FAILURE_BACKOFF_MS).toBe(24 * 60 * 60 * 1000); expect(__testing.isCacheFresh(failure, DEFAULT_REGISTRY, base.checkedAt + FAILURE_BACKOFF_MS)).toBe(false); expect(__testing.isCacheFresh(failure, DEFAULT_REGISTRY, base.checkedAt + FAILURE_BACKOFF_MS - 1)).toBe(true); }); + it("suppresses a repeat network probe for a whole day after a failure", async () => { + const failure = { ...base, outcome: "failure" as const, checkedAt: Date.now() - 23 * 60 * 60 * 1000 }; + writeFileSync(getUpdateCacheFile(), JSON.stringify(failure), "utf8"); + await __testing.runUpdateCheck({}); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("rejects a cache written by a different build", () => { expect( __testing.isCacheFresh({ ...base, currentVersion: "0.0.1" }, DEFAULT_REGISTRY, base.checkedAt), @@ -571,6 +581,7 @@ describe("runUpdateCheck", () => { respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(takePendingUpdateNotice()).not.toBeNull(); __testing.reset(); __testing.setInstalled(true); @@ -578,6 +589,7 @@ describe("runUpdateCheck", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(getUpdateStatus().state).toBe("update-available"); + // Already delivered before the restart, so it is not repeated. expect(takePendingUpdateNotice()).toBeNull(); }); @@ -1128,3 +1140,202 @@ describe("privacy: status_get reporting is offline", () => { expect(getUpdateStatus().cacheFile).toBe(getUpdateCacheFile()); }); }); + +// --------------------------------------------------------------------------- +// Code review follow-ups: the notice is only "spent" once a caller has actually +// received it, one probe per process, and hostile cache content stays inert. +// --------------------------------------------------------------------------- + +describe("notice delivery is what marks a version as notified", () => { + it("does not record the target while the notice is still pending", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + // The probe found something, but nobody has been told yet. + expect(getUpdateStatus().state).toBe("update-available"); + expect(readCacheFile()["notifiedFor"]).toEqual([]); + }); + + it("records the target only when the notice is handed to a caller", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + expect(readCacheFile()["notifiedFor"]).toEqual([]); + + expect(takePendingUpdateNotice()).not.toBeNull(); + expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + }); + + it("survives a process exit before the notice was delivered", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + // Server exits here: no tool call ever consumed the notice. + + __testing.reset(); + __testing.setInstalled(true); + await __testing.runUpdateCheck({}); // fresh cache, no network + + expect(fetchMock).toHaveBeenCalledTimes(1); + const notice = takePendingUpdateNotice(); + expect(notice?.updateAvailable.latest).toBe(EXPECTED_LATEST); + expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + }); + + it("keeps replaying the notice until one restart actually delivers it", async () => { + respondWith(packument(tagsFixture())); + for (let attempt = 0; attempt < 3; attempt += 1) { + __testing.reset(); + __testing.setInstalled(true); + await __testing.runUpdateCheck({}); + expect(readCacheFile()["notifiedFor"]).toEqual([]); + } + + expect(takePendingUpdateNotice()).not.toBeNull(); + expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + }); + + it("merges with the cache written by another process before delivery", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + // Another server instance notified about a different build meanwhile. + const concurrent = { ...readCacheFile(), notifiedFor: ["7.7.7"] }; + writeFileSync(getUpdateCacheFile(), JSON.stringify(concurrent), "utf8"); + + expect(takePendingUpdateNotice()).not.toBeNull(); + expect(readCacheFile()["notifiedFor"]).toEqual(["7.7.7", EXPECTED_LATEST]); + }); + + it("does not recreate a cache that logout deleted", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + removeUpdateCache(); + expect(cacheExists()).toBe(false); + + // The pending notice is still delivered, but no file comes back. + expect(takePendingUpdateNotice()).not.toBeNull(); + expect(cacheExists()).toBe(false); + }); + + it("never writes on delivery when there is nothing to deliver", async () => { + respondWith(packument({ latest: PACKAGE_VERSION, ...(CHANNEL ? { [CHANNEL]: PACKAGE_VERSION } : {}) })); + await __testing.runUpdateCheck({}); + + const before = readFileSync(getUpdateCacheFile(), "utf8"); + expect(takePendingUpdateNotice()).toBeNull(); + expect(readFileSync(getUpdateCacheFile(), "utf8")).toBe(before); + }); +}); + +describe("startUpdateCheck runs at most one probe per process", () => { + it("ignores a second call while the first is still in flight", async () => { + respondWith(packument(tagsFixture())); + + startUpdateCheck({}); + startUpdateCheck({}); + startUpdateCheck({}); + await __testing.settle(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("ignores a second call after the first has completed", async () => { + respondWith(packument(tagsFixture())); + startUpdateCheck({}); + await __testing.settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + startUpdateCheck({}); + await __testing.settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("hostile cache content stays inert", () => { + const hostile = { + version: 1, + checkedAt: Date.now(), + currentVersion: PACKAGE_VERSION, + registry: DEFAULT_REGISTRY, + outcome: "success" as const, + latest: NEWER_STABLE, + channelVersion: NEWER_CHANNEL ?? NEWER_STABLE, + notifiedFor: [] as string[], + }; + + it("ignores a prototype-polluting channel tag from the cache file", async () => { + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ ...hostile, channelTag: "__proto__" }), + "utf8", + ); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(({} as Record)["polluted"]).toBeUndefined(); + expect(Object.prototype).not.toHaveProperty("latest"); + // Falls back to the stable tag rather than trusting the hostile name. + expect(getUpdateStatus().latestVersion).toBe(NEWER_STABLE); + }); + + it("ignores an over-long or non-string channel tag from the cache file", async () => { + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ ...hostile, channelTag: "a".repeat(500) }), + "utf8", + ); + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().latestVersion).toBe(NEWER_STABLE); + + __testing.reset(); + __testing.setInstalled(true); + writeFileSync(getUpdateCacheFile(), JSON.stringify({ ...hostile, channelTag: 42 }), "utf8"); + await __testing.runUpdateCheck({}); + expect(getUpdateStatus().latestVersion).toBe(NEWER_STABLE); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("getUpdateStatus stays cheap and quiet", () => { + it("omits the registry and any cached version when disabled", async () => { + process.env.SPE_MCP_UPDATE_CHECK = "false"; + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ + version: 1, + checkedAt: Date.now(), + currentVersion: PACKAGE_VERSION, + registry: DEFAULT_REGISTRY, + outcome: "success", + latest: NEWER_STABLE, + notifiedFor: [], + }), + "utf8", + ); + await __testing.runUpdateCheck({}); + + const status = getUpdateStatus(); + + expect(status.enabled).toBe(false); + expect(status.state).toBe("disabled"); + expect(status.registry).toBeUndefined(); + expect(status.latestVersion).toBeUndefined(); + expect(status.lastCheckedAt).toBeUndefined(); + expect(status.cacheFile).toBe(getUpdateCacheFile()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("serves an in-memory result without re-reading the cache file", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + + // Corrupting the file must not disturb an already-resolved status. + writeFileSync(getUpdateCacheFile(), "{not json", "utf8"); + + const status = getUpdateStatus(); + expect(status.state).toBe("update-available"); + expect(status.latestVersion).toBe(EXPECTED_LATEST); + expect(status.registry).toBe(DEFAULT_REGISTRY); + }); +}); diff --git a/src/update-check.ts b/src/update-check.ts index af1710e..1499d13 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -58,9 +58,9 @@ * over-long keys/values are dropped, and every version is validated by the * strict SemVer parser before it is compared or shown. * - Results are cached under the server data dir with the same owner-only - * secure-fs primitives as the token cache (0700 dir / 0600 file, no symlink - * traversal), with a TTL plus a shorter failure backoff so a broken network is - * not re-probed on every start. + * secure-fs primitives as the token cache (SEC-003: 0700 dir / 0600 file, no + * symlink traversal), with a 24h TTL applied to successes *and* failures so + * the "at most one request per day" promise holds even offline. * * KNOWN LIMITATION (accepted tradeoff, not a sign-off) * - Node's built-in `fetch` does not honour `HTTP_PROXY` / `HTTPS_PROXY` / @@ -68,6 +68,10 @@ * which this package deliberately does not take. On a proxy-only network the * probe simply fails closed (silent no-op) rather than bypassing the proxy. * Operators who must not egress at all should turn the check off outright. + * - Cache writes are last-writer-wins. `secure-fs` has no compare-and-swap, and + * this change deliberately does not alter that shared primitive. Two servers + * delivering a notice at the same instant can therefore drop one suppression + * entry, costing at most one extra notice; tracked as follow-up work. * * ZERO-NETWORK OPT-OUTS — each skips the check entirely (no request, no notice, * no cache read, no cache write): `--no-update-check`, `SPE_MCP_UPDATE_CHECK=false`, @@ -97,8 +101,15 @@ export const MAX_RESPONSE_BYTES = 64 * 1024; /** How long a successful probe is reused before re-checking. */ export const CHECK_TTL_MS = 24 * 60 * 60 * 1000; -/** How long a failed probe is remembered before retrying (shorter than the TTL). */ -export const FAILURE_BACKOFF_MS = 6 * 60 * 60 * 1000; +/** + * How long a failed probe is remembered before retrying. + * + * Deliberately identical to {@link CHECK_TTL_MS}: the documented promise is "at + * most one request per day", and a shorter failure backoff would quietly make + * that promise false for anyone who is offline (a failing probe would be retried + * several times a day). Success and failure are both remembered for 24h. + */ +export const FAILURE_BACKOFF_MS = CHECK_TTL_MS; /** Cap on remembered "already told the user about this version" entries. */ const MAX_NOTIFIED_ENTRIES = 10; @@ -388,6 +399,24 @@ async function readCappedText(response: Response, cap: number): Promise MAX_TAG_NAME_LENGTH) return false; + return !FORBIDDEN_KEYS.has(name); +} + +/** A fresh `name -> version` map that cannot inherit anything from `Object`. */ +function emptyTagMap(): Record { + return Object.create(null) as Record; +} + /** * Extract a trustworthy `name -> version` map from a raw packument body. * @@ -397,7 +426,7 @@ async function readCappedText(response: Response, cap: number): Promise { - const result: Record = Object.create(null) as Record; + const result = emptyTagMap(); let parsed: unknown; try { @@ -411,8 +440,7 @@ function extractDistTags(raw: string): Record { if (typeof tags !== "object" || tags === null || Array.isArray(tags)) return result; for (const [name, value] of Object.entries(tags as Record)) { - if (FORBIDDEN_KEYS.has(name)) continue; - if (name.length === 0 || name.length > MAX_TAG_NAME_LENGTH) continue; + if (!isSafeTagName(name)) continue; if (typeof value !== "string" || value.length > MAX_TAG_VALUE_LENGTH) continue; if (parseSemver(value) === null) continue; result[name] = value; @@ -526,7 +554,9 @@ function readCache(): UpdateCache | null { registry: candidate.registry, outcome: candidate.outcome, latest: typeof candidate.latest === "string" ? candidate.latest : undefined, - channelTag: typeof candidate.channelTag === "string" ? candidate.channelTag : undefined, + // The cache file is untrusted input: a tampered `channelTag` is used as a + // map key later, so it goes through the same guard as packument keys. + channelTag: isSafeTagName(candidate.channelTag) ? candidate.channelTag : undefined, channelVersion: typeof candidate.channelVersion === "string" ? candidate.channelVersion : undefined, notifiedFor: notified.slice(-MAX_NOTIFIED_ENTRIES), @@ -600,10 +630,7 @@ function newerTagVersion( * cache file only and never touches the network. */ function cachedTargetVersion(cache: UpdateCache): string | undefined { - const tags: Record = {}; - if (cache.latest) tags["latest"] = cache.latest; - if (cache.channelTag && cache.channelVersion) tags[cache.channelTag] = cache.channelVersion; - + const tags = buildTagsFromCache(cache); const current = parseSemver(PACKAGE_VERSION); if (current) { const channel = releaseChannel(current); @@ -793,21 +820,13 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { }; // Per-target suppression: each newer version is announced exactly once, even - // across restarts, so the notice never becomes background noise. + // across restarts. The suppression entry is persisted by + // `takePendingUpdateNotice()` at *delivery* time, not here: a process that + // probes and then exits before any tool call would otherwise burn the only + // announcement without the user ever seeing it. if (notifiedFor.includes(latest)) return; pendingNotice = { text: renderNotice(update), updateAvailable: update }; - writeCache({ - version: 1, - checkedAt, - currentVersion: PACKAGE_VERSION, - registry, - outcome: "success", - latest: tags?.["latest"], - channelTag: channel ?? undefined, - channelVersion: channel ? tags?.[channel] : undefined, - notifiedFor: [...notifiedFor, latest], - }); logger.debug(`Update available: ${PACKAGE_VERSION} -> ${latest}`); } catch { // A best-effort courtesy must never affect the server. @@ -816,9 +835,11 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { /** Rebuild the tag map from a fresh success cache entry (no network). */ function buildTagsFromCache(cache: UpdateCache): Record { - const tags: Record = Object.create(null) as Record; + const tags = emptyTagMap(); if (cache.latest) tags["latest"] = cache.latest; - if (cache.channelTag && cache.channelVersion) tags[cache.channelTag] = cache.channelVersion; + if (isSafeTagName(cache.channelTag) && cache.channelVersion) { + tags[cache.channelTag] = cache.channelVersion; + } return tags; } @@ -829,8 +850,12 @@ function buildTagsFromCache(cache: UpdateCache): Record { /** * Kick off the update check. Returns immediately and is never awaited by the * server; the work happens on a detached promise that swallows all errors. + * + * Re-entrant calls are ignored while a probe is still in flight, so a caller + * that wires this up more than once can never produce a duplicate request. */ export function startUpdateCheck(options: StartUpdateCheckOptions = {}): void { + if (inFlight) return; try { inFlight = runUpdateCheck(options).catch(() => undefined); } catch { @@ -844,33 +869,69 @@ export function startUpdateCheck(options: StartUpdateCheckOptions = {}): void { * Returning-and-clearing is what makes the notice appear on exactly one tool * result: whichever call happens to run after the probe resolves gets it, and * every later call sees `null`. + * + * Cross-process suppression is persisted *here*, at delivery, rather than when + * the probe found the update: a process that exits before any tool call leaves + * the cache untouched, so the next process still announces the version. The + * cache is re-read immediately before writing so a concurrent process's entries + * are merged rather than clobbered. Known residual risk: two processes that + * deliver at the same instant can still interleave (last writer wins) and one + * suppression entry may be lost, costing at most one extra notice; fixing that + * needs compare-and-swap support in `secure-fs`, tracked as follow-up work. + * + * An absent cache file is never re-created here: `spe-mcp logout` deletes it, + * and delivery must not resurrect state the user just erased. */ export function takePendingUpdateNotice(): UpdateNotice | null { const notice = pendingNotice; pendingNotice = null; + if (notice) persistNotified(notice.updateAvailable.latest); return notice; } +/** Record `version` as announced, merging into whatever is on disk right now. */ +function persistNotified(version: string): void { + try { + const cache = readCache(); + // No cache (never written, or deleted by logout) => nothing to update. + if (!cache) return; + if (cache.notifiedFor.includes(version)) return; + writeCache({ ...cache, notifiedFor: [...cache.notifiedFor, version] }); + } catch { + // Best-effort: at worst the notice is shown once more next run. + } +} + /** * Current check state, for `status_get` and diagnostics. * * Read-only and strictly local: this never makes a network request. When the - * live status has nothing to say (opted out, or a fresh process that has not - * probed yet) the locally cached result is surfaced instead, so a user can - * always see what is stored, when it was stored, and where the file lives. + * live status has nothing to say (a fresh process that has not probed yet) the + * locally cached result is surfaced instead, so a user can always see what is + * stored, when it was stored, and where the file lives. */ export function getUpdateStatus(): UpdateCheckStatus { const cacheFile = getUpdateCacheFile(); - const cache = readCache(); + // Opted out: report the local file location only. No disk read, and no + // registry is named — nothing would ever be contacted. `startUpdateCheck` + // resolves a skip synchronously before its first `await`, so this is already + // accurate by the time any tool can call in. + if (!status.enabled) return { ...status, cacheFile }; + + // This process already has a result, so the in-memory status is at least as + // current as the file: skip the disk read entirely. + if (status.lastCheckedAt) { + return { ...status, cacheFile, registry: resolveRegistry() ?? DEFAULT_REGISTRY }; + } + + const cache = readCache(); return { ...status, cacheFile, - registry: status.registry ?? cache?.registry ?? resolveRegistry() ?? DEFAULT_REGISTRY, - latestVersion: status.latestVersion ?? (cache ? cachedTargetVersion(cache) : undefined), - lastCheckedAt: - status.lastCheckedAt ?? - (cache ? new Date(cache.checkedAt).toISOString() : undefined), + registry: cache?.registry ?? resolveRegistry() ?? DEFAULT_REGISTRY, + latestVersion: cache ? cachedTargetVersion(cache) : undefined, + lastCheckedAt: cache ? new Date(cache.checkedAt).toISOString() : undefined, }; } From 5ef455dace9182261bbe793aa44e600ee2338d08 Mon Sep 17 00:00:00 2001 From: grjoseph Date: Thu, 20 Aug 2026 20:51:02 -0700 Subject: [PATCH 3/9] test(update-check): add spawned notice regression and correct disclosure wording Adds the affirmative end-to-end regression that the earlier notice-delivery fix was missing, and corrects documentation and CLI wording that described the update check inaccurately. Regression coverage (AB#3219517): - New spawned JSON-RPC suite drives a real server over stdio from a package layout that looks registry-installed, proving the sequence a unit test cannot: connect and exit before any tool call leaves the notice undelivered, the next process still emits it on the first successful tool result, and a third process stays silent. The notice is therefore never lost and never repeated. - Deleting the cache mid-flight no longer lets a pending delivery recreate it, and both CLI credential-clearing paths are asserted to clear the cache. Wording corrections: - Opting out of telemetry suppresses the registry request entirely; it is a skip reason, not a request with a header removed. Documentation no longer implies a request still happens. - The public npm registry and GitHub are described as not being Microsoft M365 or Azure Online Services and therefore outside the Product Terms, DPA, and EUDB commitments, rather than merely "not Microsoft". - Disclosure tables now list the standard TLS and HTTP connection metadata that any HTTPS request reveals, alongside IP address and User-Agent. - CLI help, option types, and docs name SPE_MCP_UPDATE_CHECK=false as the preferred control; SPE_NO_UPDATE_CHECK is labelled a legacy alias throughout. No behaviour change to the check itself, no new runtime dependencies, and auto-update remains out of scope. AB#3219463 AB#3219517 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 17 ++- PRIVACY.md | 41 ++++-- README.md | 55 ++++--- docs/DATA-FLOW.md | 19 ++- docs/SECURITY-CONTROLS.md | 2 +- docs/TROUBLESHOOTING.md | 5 +- src/cli.ts | 2 +- src/protocol-e2e.test.ts | 6 +- src/types.ts | 6 +- src/update-check.test.ts | 28 ++++ src/update-check.ts | 25 +++- src/update-notice-e2e.test.ts | 268 ++++++++++++++++++++++++++++++++++ 12 files changed, 407 insertions(+), 67 deletions(-) create mode 100644 src/update-notice-e2e.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 18be827..4c238de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). reported separately), adds **zero new runtime dependencies**, and is skipped automatically in CI and when running from a source checkout. Disable it with `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, `SPE_NO_UPDATE_CHECK=1` - (backward-compatible alias), `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false`; + (legacy alias), `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false` (the telemetry + opt-out suppresses the registry request entirely); when disabled, **no network request, stderr notice, or cache write occurs**. Point it at a mirror with `SPE_NPM_REGISTRY` (HTTPS-only). - **Transparency for the update check.** Before the first registry request in a process, the @@ -34,9 +35,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). losing it. - **Boundary disclosure.** `README.md`, `PRIVACY.md`, `docs/DATA-FLOW.md`, `docs/SECURITY-CONTROLS.md`, and `docs/TROUBLESHOOTING.md` document that - `registry.npmjs.org` (npm, Inc./GitHub) is the only endpoint **outside the Microsoft 365 / - Azure compliance boundary** and outside EU Data Boundary commitments, that the connection - discloses IP address / static `User-Agent` / request time, that no auto-update exists, and + `registry.npmjs.org` (npm, Inc./GitHub) is **not a Microsoft 365 or Azure Online Service** and + is therefore the only endpoint **outside the Microsoft 365 / Azure compliance boundary** and + not covered by the Microsoft Product Terms, the DPA, or EU Data Boundary commitments; that the + connection discloses IP address, the static `User-Agent`, standard TLS/HTTP connection + metadata, and the request time; that no auto-update exists; and that Node's built-in `fetch` cannot route through `HTTP(S)_PROXY` — an open, unresolved tradeoff accepted to preserve the zero-runtime-dependency budget. - **Per-instance data directory.** New `--data-dir ` flag and `SPE_DATA_DIR` @@ -53,8 +56,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). exactly one fixed package path with no query string; redirects and cross-host responses are rejected. It is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no install GUID, machine, user, tenant, subscription, - correlation, or session identifier**, and omits the product `User-Agent` when - `SPE_MCP_COLLECT_TELEMETRY=false`. It is bounded by a 2-second timeout and a 64 KB response + correlation, or session identifier**, and discloses only what any HTTPS connection reveals + (IP address, the static product `User-Agent`, and standard TLS/HTTP connection metadata). + Setting `SPE_MCP_COLLECT_TELEMETRY=false` suppresses the registry request entirely. + It is bounded by a 2-second timeout and a 64 KB response cap, parsed with strict SemVer and prototype-pollution-safe key filtering, and cached owner-only (SEC-003) with a 24-hour TTL — a failed check backs off for the same 24 hours, so at most one request per day is made either way — deleted on `logout` / diff --git a/PRIVACY.md b/PRIVACY.md index 3830d4c..42c9241 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,9 +12,9 @@ organization's agreements with Microsoft. **The tool opens no dedicated usage-analytics channel and sends no personal, tenant, or per-user data to Microsoft.** The only Microsoft-bound attribution signal is a static product `User-Agent` token, which is on by default and can be turned off (see -[Turning it off](#turning-it-off)). The only **non-Microsoft** destination is an anonymous -public-package lookup on the npm registry used to notify you of newer releases, which can -also be turned off. Specifically: +[Turning it off](#turning-it-off)). The only destination that is **not a Microsoft 365 or Azure +Online Service** is an anonymous public-package lookup on the npm registry used to notify you of +newer releases, which can also be turned off. Specifically: - **No telemetry channel.** The tool does not implement application telemetry and does not "phone home." Diagnostic logs are written to the local process's **stderr only**, with @@ -34,16 +34,18 @@ also be turned off. Specifically: aggregate traffic driven by this tool. It is a request header on calls you already make — not a separate data feed — and it is **on by default**; set `SPE_MCP_COLLECT_TELEMETRY=false` to omit it (see [Turning it off](#turning-it-off)). -- **Update check (public npm registry — the only non-Microsoft destination, and the only - destination outside the compliance boundary).** At most once every 24 hours the tool reads +- **Update check (public npm registry — the only destination that is not a Microsoft 365 or + Azure Online Service, and the only destination outside the compliance boundary).** At most + once every 24 hours the tool reads the published version list for `@microsoft/spe-mcp` from the public npm registry (`https://registry.npmjs.org`, override with `SPE_NPM_REGISTRY`) so it can tell you when a newer release exists (`src/update-check.ts`). - > **Boundary disclosure.** `registry.npmjs.org` is operated by **npm, Inc. (GitHub)**, not by - > Microsoft 365 or Azure. It is **outside the Microsoft 365 / Azure compliance boundary** and - > outside any **EU Data Boundary** commitment that applies to your tenant. Data sent there is - > not covered by the Microsoft Product Terms or the DPA; it is governed by the + > **Boundary disclosure.** `registry.npmjs.org` is operated by **npm, Inc. (GitHub)**. It is + > **not a Microsoft 365 or Azure Online Service**, so it is **outside the Microsoft 365 / + > Azure compliance boundary** and outside any **EU Data Boundary** commitment that applies to + > your tenant. Data sent there is **not covered by the Microsoft Product Terms or by the + > Microsoft Products and Services Data Protection Addendum (DPA)**; it is governed by the > [npm privacy policy](https://docs.npmjs.com/policies/privacy). **Exactly one request is made,** to the exact package path with no query string and no @@ -61,9 +63,15 @@ also be turned off. Specifically: |------------------|-----| | Your **IP address** (or your egress/NAT address) | Inherent to making an HTTPS connection | | The **package name** `@microsoft/spe-mcp` | It is the resource being requested | - | The static product **`User-Agent`** `spe-mcp-server/` | Standard client identification; **omitted entirely** when `SPE_MCP_COLLECT_TELEMETRY=false` | + | The static product **`User-Agent`** `spe-mcp-server/` | Standard client identification | + | Standard **TLS/HTTP connection metadata** — TLS handshake parameters and the SNI host name, the `Host` and `Accept` request headers, and connection/request timing | Inherent to any HTTPS request; not set or enriched by this tool | | Approximate **time of the request** | Inherent to any server-side request log | + Setting `SPE_MCP_COLLECT_TELEMETRY=false` **suppresses the registry request entirely** — it is + a skip reason, so no connection is opened and none of the rows above occur. (The shared + user-agent helper also omits the product `User-Agent` when telemetry is off; for this endpoint + that is defense in depth only, because no request is made at all.) + **What is never sent:** no credentials, tokens, cookies, or `Authorization` header; no `.npmrc` and no npm subprocess; **no install GUID, machine identifier, hostname, user name, tenant ID, subscription ID, correlation ID, or session ID**; no usage, prompt, or content @@ -138,17 +146,18 @@ carry the underlying tool's default `User-Agent` instead (e.g. the Azure CLI's o `az`/`azd`, or the Node runtime default for direct Graph calls), whose logging is governed by those services' own terms. -The **update check** — the only non-Microsoft outbound call, and the only call that leaves the -Microsoft 365 / Azure compliance boundary — is on by default in published installs. Any one of -the following disables it completely: +The **update check** — the only outbound call to a service that is **not a Microsoft 365 or Azure +Online Service**, and therefore the only call that leaves the Microsoft 365 / Azure compliance +boundary (and the Product Terms / DPA / EUDB commitments) — is on by default in published +installs. Any one of the following disables it completely: | Opt-out | Effect | |---------|--------| -| `SPE_MCP_UPDATE_CHECK=false` | **Preferred.** Disables the check for every instance in that environment (`0`, `off`, `no` also accepted) | +| `SPE_MCP_UPDATE_CHECK=false` | **Preferred public control.** Disables the check for every instance in that environment (`0`, `off`, `no` also accepted) | | `spe-mcp start --no-update-check` | Disables the check for that server instance | -| `SPE_NO_UPDATE_CHECK=1` | Backward-compatible alias, honoured identically | +| `SPE_NO_UPDATE_CHECK=1` | **Legacy alias** for `SPE_MCP_UPDATE_CHECK=false`, honoured identically | | `NO_UPDATE_NOTIFIER=1` | Community-standard opt-out, honoured identically | -| `SPE_MCP_COLLECT_TELEMETRY=false` | Opting out of the product `User-Agent` also disables the update check | +| `SPE_MCP_COLLECT_TELEMETRY=false` | Opting out of telemetry suppresses the registry request entirely | When disabled, the tool makes **no registry request, prints no collection notice, and writes no update-check cache file** — the code path exits before any network or disk access. `status_get` diff --git a/README.md b/README.md index 08c01ee..f0e8495 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,11 @@ How it behaves: redirects rejected. No credentials, cookies, `.npmrc`, or `npm` subprocess are involved, and **no install GUID, machine, user, tenant, subscription, or session identifier** is sent. As with any HTTPS request, npm sees your IP - address, the static `User-Agent` `spe-mcp-server/` (omitted when - telemetry is off), and the time of the request. + address, the static `User-Agent` `spe-mcp-server/`, standard TLS/HTTP + connection metadata (TLS handshake and SNI, `Host`/`Accept` headers, timing), + and the time of the request. Opting out of telemetry + (`SPE_MCP_COLLECT_TELEMETRY=false`) suppresses the request entirely, so none of + that is disclosed. - **Announced.** Before the first check in a process, a one-time notice is printed to **stderr** naming the endpoint, the boundary, and the opt-out. - **Cached locally.** The result is stored owner-only at @@ -160,9 +163,11 @@ How it behaves: the path. > ⚠️ **Boundary note.** `registry.npmjs.org` is operated by npm, Inc. (GitHub). -> It is the **only** endpoint this server contacts that is **outside the -> Microsoft 365 / Azure compliance boundary** and outside EU Data Boundary -> commitments. Disable the update check to remove it entirely. +> It is **not a Microsoft 365 or Azure Online Service**, so it is not covered by +> the Microsoft Product Terms, the Microsoft Products and Services Data +> Protection Addendum (DPA), or the EU Data Boundary. It is the **only** endpoint +> this server contacts that is **outside the Microsoft 365 / Azure compliance +> boundary**. Disable the update check to remove it entirely. > **Known limitation.** Node's built-in `fetch` does not honour `HTTP_PROXY` / > `HTTPS_PROXY` / `NO_PROXY`, so this request cannot be routed through an egress @@ -177,9 +182,9 @@ CI, and can be turned off explicitly: ```bash SPE_MCP_UPDATE_CHECK=false spe-mcp start # preferred env var spe-mcp start --no-update-check # flag -SPE_NO_UPDATE_CHECK=1 spe-mcp start # backward-compatible alias +SPE_NO_UPDATE_CHECK=1 spe-mcp start # legacy alias NO_UPDATE_NOTIFIER=1 spe-mcp start # community-standard opt-out -SPE_MCP_COLLECT_TELEMETRY=false spe-mcp start # telemetry opt-out also disables it +SPE_MCP_COLLECT_TELEMETRY=false spe-mcp start # telemetry opt-out suppresses the request entirely ``` When disabled, **no network request, no stderr notice, and no cache write happen @@ -262,9 +267,9 @@ The server accepts configuration via CLI flags or environment variables: | `--read-only` | `SPE_READ_ONLY` | Advertise/allow only read/list/get/search tools; reject mutating calls | | `--tools` | `SPE_TOOLS` | Restrict exposed tools to a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated tool list | | `--data-dir` | `SPE_DATA_DIR` | Directory for the token cache + provisioning state (default `~/.spe-mcp`). Point each instance at a unique **absolute** path (or `~/...`; CWD-relative paths are rejected) to run multiple servers without clobbering state | -| `--no-update-check` | `SPE_MCP_UPDATE_CHECK=false` | Disable the once-a-day npm version check that tells you when a newer server release is published (see [Update notifications](#update-notifications)). Also honours `SPE_NO_UPDATE_CHECK=1` (alias), the community-standard `NO_UPDATE_NOTIFIER=1`, and `SPE_MCP_COLLECT_TELEMETRY=false`. When disabled, no network request, stderr notice, or cache write occurs | -| _(none)_ | `SPE_NPM_REGISTRY` | Registry base URL for the update check (default `https://registry.npmjs.org` — npm, Inc./GitHub, **outside the Microsoft 365 / Azure compliance boundary**). **HTTPS only**; credentials, query strings, and fragments are rejected | -| _(none)_ | `SPE_MCP_COLLECT_TELEMETRY` | Product `User-Agent` attribution token on outbound Graph/ARM requests. On by default; set to `false` to opt out — this also disables the update check entirely (see [PRIVACY.md](PRIVACY.md)) | +| `--no-update-check` | `SPE_MCP_UPDATE_CHECK=false` | Disable the once-a-day npm version check that tells you when a newer server release is published (see [Update notifications](#update-notifications)). Also honours `SPE_NO_UPDATE_CHECK=1` (**legacy alias**), the community-standard `NO_UPDATE_NOTIFIER=1`, and `SPE_MCP_COLLECT_TELEMETRY=false`. When disabled, no network request, stderr notice, or cache write occurs | +| _(none)_ | `SPE_NPM_REGISTRY` | Registry base URL for the update check (default `https://registry.npmjs.org` — npm, Inc./GitHub, **not a Microsoft 365 or Azure Online Service** and outside the Microsoft 365 / Azure compliance boundary). **HTTPS only**; credentials, query strings, and fragments are rejected | +| _(none)_ | `SPE_MCP_COLLECT_TELEMETRY` | Product `User-Agent` attribution token on outbound Graph/ARM requests. On by default; set to `false` to opt out — this also suppresses the update-check request entirely (see [PRIVACY.md](PRIVACY.md)) | > The CLI flag wins when both a flag and its env var are set. Run > `spe-mcp start --help` to see the authoritative option list and descriptions. @@ -669,22 +674,26 @@ details see [PRIVACY.md](PRIVACY.md) and [docs/DATA-FLOW.md](docs/DATA-FLOW.md); handling of data you send to its online services is described in the [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement). -The one **non-Microsoft** destination is the public npm registry -(`https://registry.npmjs.org`, operated by npm, Inc./GitHub), contacted at most once a day by -the [update check](#update-notifications) to read the published version list for -`@microsoft/spe-mcp`. ⚠️ This endpoint is **outside the Microsoft 365 / Azure compliance -boundary**, is not covered by the Microsoft Product Terms or DPA, and is outside EU Data -Boundary commitments. The request is **unauthenticated and anonymous** — exactly +The one destination that is **not a Microsoft 365 or Azure Online Service** is the public npm +registry (`https://registry.npmjs.org`, operated by npm, Inc./GitHub), contacted at most once a +day by the [update check](#update-notifications) to read the published version list for +`@microsoft/spe-mcp`. ⚠️ Because it is not a Microsoft Online Service, it is **not covered by the +Microsoft Product Terms, the Microsoft Products and Services Data Protection Addendum (DPA), or +the EU Data Boundary**, and it is **outside the Microsoft 365 / Azure compliance +boundary**. The request is **unauthenticated and anonymous** — exactly `GET https://registry.npmjs.org/@microsoft%2fspe-mcp` with no query string, no credentials or cookies, redirects rejected, and **no install GUID, machine, user, tenant, subscription, correlation, or session identifier**; it is an ordinary public package lookup, identical to what `npm view` would issue. As with any HTTPS request, npm can observe your **IP address**, -the static `User-Agent` (omitted when telemetry is off), and the **time of the request**; +the static `User-Agent`, **standard TLS/HTTP connection metadata** (TLS handshake and SNI, +`Host`/`Accept` headers, request timing), and the **time of the request**; those are disclosed by the connection itself, not added by this tool. Nothing is downloaded, installed, or executed — there is **no auto-update**. Before the first check, a one-time notice naming the endpoint and the opt-out is printed to **stderr**. Disable it with -`SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, `SPE_NO_UPDATE_CHECK=1`, -`NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false`; when disabled, the request is +`SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, `SPE_NO_UPDATE_CHECK=1` (legacy +alias), `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false` — the telemetry opt-out +suppresses the registry request **entirely**, it does not merely drop the `User-Agent`. When +disabled, the request is never made and nothing is cached. It is also skipped automatically in CI and in source checkouts. The cached result lives at `/update-check.json`, is retained until you delete it, and is removed by `spe-mcp logout` / `spe-mcp auth --reset`. npm's own handling of @@ -713,10 +722,12 @@ This tool performs **no independent cross-region processing** and stores no cust of its own. Because it calls your own tenant's Microsoft Graph and Azure endpoints, data location, residency, and **EU Data Boundary (EUDB)** commitments follow the underlying Microsoft Online Services and your tenant configuration — not this tool. The only additional -endpoint is the read-only, public [Microsoft Learn MCP](https://learn.microsoft.com/api/mcp) +Microsoft endpoint is the read-only, public [Microsoft Learn MCP](https://learn.microsoft.com/api/mcp) documentation service (no authentication, no customer data; host-validated per **SEC-007**), -which can be disabled with `--tools`. All outbound calls target Microsoft-operated services; -the server contacts **no non-Microsoft services**. +which can be disabled with `--tools`. Apart from the opt-out-able npm update check described +above, all outbound calls target Microsoft-operated services; the public npm registry is the +**only** endpoint that is **not a Microsoft 365 or Azure Online Service** and therefore the only +one outside Product Terms / DPA / EUDB coverage. ### Compliance responsibility diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index d520b6f..8abc02d 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -26,13 +26,14 @@ MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft | Microsoft Graph (`graph.microsoft.com`) | Create/manage app registrations, container types, containers, and content | Your delegated token | The requests you invoke, in your tenant | Microsoft first-party, in-tenant | | Azure Resource Manager (`management.azure.com`) | Register the `Microsoft.Syntex` provider and wire SPE billing to your subscription | Your Azure token | ARM requests in your subscription | Microsoft first-party, in-subscription | | Microsoft Learn MCP (`learn.microsoft.com/api/mcp`) | Read-only public documentation lookup (`docs_search`) | **None** | Documentation queries only — **no customer data** | Microsoft first-party, public docs | -| npm registry (`registry.npmjs.org`, override `SPE_NPM_REGISTRY`) | Update check: read the published version list for `@microsoft/spe-mcp` at most once per 24 h (control **SEC-008**) | **None** | Package name in the request path only. Necessarily discloses your **IP address**, the static **`User-Agent`** `spe-mcp-server/` (omitted when telemetry is off), and the request time. **No** customer data, tenant/user/subscription identifier, install GUID, machine name, session ID, or usage data | ⚠️ **Third party (npm, Inc. / GitHub) — OUTSIDE the Microsoft 365 / Azure compliance boundary and outside EUDB** | +| npm registry (`registry.npmjs.org`, override `SPE_NPM_REGISTRY`) | Update check: read the published version list for `@microsoft/spe-mcp` at most once per 24 h (control **SEC-008**) | **None** | Package name in the request path only. Necessarily discloses your **IP address**, the static **`User-Agent`** `spe-mcp-server/`, **standard TLS/HTTP connection metadata** (TLS handshake and SNI, `Host`/`Accept` headers, request timing), and the request time. Opting out of telemetry suppresses this request entirely. **No** customer data, tenant/user/subscription identifier, install GUID, machine name, session ID, or usage data | ⚠️ **Third party (npm, Inc. / GitHub) — not a Microsoft 365 or Azure Online Service; OUTSIDE the Microsoft 365 / Azure compliance boundary and not covered by the Microsoft Product Terms, DPA, or EUDB** | Only two calls leave your tenant, and neither carries customer data: - The **Microsoft Learn documentation lookup** is unauthenticated and out-of-tenant; it is host-validated before use (control **SEC-007**) and can be disabled with `--tools`. -- The **npm update check** is the only **non-Microsoft** destination and the only endpoint +- The **npm update check** is the only destination that is **not a Microsoft 365 or Azure Online + Service** and the only endpoint **outside the Microsoft 365 / Azure compliance boundary**. It issues exactly one request — `GET https://registry.npmjs.org/@microsoft%2fspe-mcp`, the exact package path with no query string and no fragment — the same request `npm view` issues, with a 2-second timeout, a 64 KB @@ -42,7 +43,9 @@ Only two calls leave your tenant, and neither carries customer data: such request in a process, a one-time notice naming the endpoint, the boundary, and the opt-out is printed to **stderr**. It is skipped automatically in CI and source checkouts, and disabled by `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, - `SPE_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false` — in + `SPE_NO_UPDATE_CHECK=1` (legacy alias), `NO_UPDATE_NOTIFIER=1`, or + `SPE_MCP_COLLECT_TELEMETRY=false` (the telemetry opt-out suppresses the registry request + **entirely** — it does not merely drop the `User-Agent`) — in which case no request is made, no notice is printed, and no cache is written. See [PRIVACY.md](../PRIVACY.md). - **Known limitation:** Node's built-in `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` / @@ -71,11 +74,13 @@ These never leave your machine: Services operating **within the Microsoft 365 / Azure compliance boundary**. Requests you make through this tool stay within that boundary and your tenant's configured data location. - ⚠️ **One endpoint is outside that boundary:** the npm registry (`registry.npmjs.org`), - operated by npm, Inc. (GitHub). It is **not** a Microsoft Online Service, is **not** covered - by the Microsoft Product Terms or the DPA, and is **not** subject to any **EU Data Boundary** + operated by npm, Inc. (GitHub). It is **not a Microsoft 365 or Azure Online Service**, is + **not** covered by the Microsoft Product Terms or the Microsoft Products and Services Data + Protection Addendum (DPA), and is **not** subject to any **EU Data Boundary** commitment applying to your tenant. Only the package name is requested; the connection - discloses your IP address, the static `User-Agent`, and the request time. Disable the update - check to remove this endpoint entirely. + discloses your IP address, the static `User-Agent`, standard TLS/HTTP connection metadata + (TLS handshake and SNI, `Host`/`Accept` headers, request timing), and the request time. + Disable the update check to remove this endpoint entirely. - The tool performs **no independent cross-region processing** and stores **no customer content** of its own. Data location, residency, and **EU Data Boundary** commitments are determined by those underlying services and your tenant configuration — not by this tool. diff --git a/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md index 05192a4..0f9f257 100644 --- a/docs/SECURITY-CONTROLS.md +++ b/docs/SECURITY-CONTROLS.md @@ -24,7 +24,7 @@ that maps each code to a human-readable name and a one-line description. | SEC-002 | Client-safe error messages | Tool `catch` blocks surface only sanitized, consistent messages to clients; internal detail stays in server-side logs. | | SEC-003 | Secure filesystem (owner-only) | Credential and state files (token cache, server state) are written owner-only (POSIX `0o600`; ACL-governed on Windows). | | SEC-007 | Docs endpoint validation | The Microsoft Learn MCP endpoint is resolved and validated before use to prevent redirection to an untrusted host. | -| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data) and omits the product `User-Agent` when telemetry is opted out, is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), cached owner-only via SEC-003 with a 24 h TTL (a failed check backs off for the same 24 h, so at most one request per day either way) and deleted on `logout` / `auth --reset`, announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | +| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data), discloses only what any HTTPS connection reveals (IP address, the static product `User-Agent`, and standard TLS/HTTP connection metadata such as the TLS handshake/SNI, `Host`/`Accept` headers, and request timing), is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), cached owner-only via SEC-003 with a 24 h TTL (a failed check backs off for the same 24 h, so at most one request per day either way) and deleted on `logout` / `auth --reset`, announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK` (legacy alias), `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false` — the telemetry opt-out suppresses the registry request entirely rather than merely omitting the `User-Agent`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | > Adding a new safeguard? Give it the next code in its family and add a row here > so code comments and tests have a lookup. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index edf8c1b..52e6231 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -100,8 +100,9 @@ Common situations: - **No notice appears, but a newer version exists.** The check is skipped by design when running from a source checkout, in CI (`CI`, `GITHUB_ACTIONS`, `TF_BUILD`, …), or when any opt-out is set: `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, - `SPE_NO_UPDATE_CHECK=1` (alias), `NO_UPDATE_NOTIFIER=1`, or - `SPE_MCP_COLLECT_TELEMETRY=false`. The notice is also shown only once per detected version + `SPE_NO_UPDATE_CHECK=1` (legacy alias), `NO_UPDATE_NOTIFIER=1`, or + `SPE_MCP_COLLECT_TELEMETRY=false` (the telemetry opt-out suppresses the registry request + entirely; it does not merely omit the `User-Agent`). The notice is also shown only once per detected version per cache. Run `status_get` to see the **Update check** row, which reports the exact state and skip reason. When skipped, **no network request, stderr notice, or cache write occurs**. - **Offline, proxied, or firewalled registry.** The lookup has a 2-second timeout and fails diff --git a/src/cli.ts b/src/cli.ts index e583be1..66299be 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,7 +78,7 @@ program .option("--data-dir ", DATA_DIR_OPTION) .option( "--no-update-check", - "Do not contact the public npm registry to check whether a newer version of this server has been published. Can also be set via SPE_NO_UPDATE_CHECK or NO_UPDATE_NOTIFIER (truthy).", + "Do not contact the public npm registry to check whether a newer version of this server has been published. Can also be set via SPE_MCP_UPDATE_CHECK=false (preferred), SPE_NO_UPDATE_CHECK=1 (legacy alias), NO_UPDATE_NOTIFIER=1, or SPE_MCP_COLLECT_TELEMETRY=false.", ) .action( async (options: { diff --git a/src/protocol-e2e.test.ts b/src/protocol-e2e.test.ts index fb88b56..82773b8 100644 --- a/src/protocol-e2e.test.ts +++ b/src/protocol-e2e.test.ts @@ -81,8 +81,10 @@ describe("MCP protocol-level e2e (spawned dist/cli.js start)", () => { delete env.SPE_TOOLS; // SEC-008: keep this suite hermetic. The update check is fire-and-forget and // would otherwise reach registry.npmjs.org from a test process. Opting out - // also exercises the documented kill switch over the real wire. - env.SPE_NO_UPDATE_CHECK = "1"; + // also exercises the documented kill switch over the real wire — using the + // preferred public control (`SPE_NO_UPDATE_CHECK` is only a legacy alias). + env.SPE_MCP_UPDATE_CHECK = "false"; + delete env.SPE_NO_UPDATE_CHECK; delete env.SPE_NPM_REGISTRY; transport = new StdioClientTransport({ diff --git a/src/types.ts b/src/types.ts index 452089f..f5ac13b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,8 +108,10 @@ export interface ServerConfig { * Update awareness (SEC-008). When `false`, the server never contacts the npm * registry to see whether a newer build has been published — no network call, * no cache read, no cache write. Defaults to enabled; set to `false` by the - * `--no-update-check` flag, and independently overridden by the - * `SPE_NO_UPDATE_CHECK` / `NO_UPDATE_NOTIFIER` environment variables. + * `--no-update-check` flag, and independently overridden by the preferred + * `SPE_MCP_UPDATE_CHECK=false` environment variable (or its legacy alias + * `SPE_NO_UPDATE_CHECK=1`, the community convention `NO_UPDATE_NOTIFIER=1`, + * `SPE_MCP_COLLECT_TELEMETRY=false`, or a CI environment). */ updateCheck?: boolean; } diff --git a/src/update-check.test.ts b/src/update-check.test.ts index f99c5af..971bbd9 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -1086,6 +1086,34 @@ describe("privacy: cache retention and deletion", () => { }).not.toThrow(); }); + // Deleting the cache is a privacy promise: after logout there must be no + // residue, and delivering a notice that was already in flight must not quietly + // recreate the file the user just asked to be removed. + it("does not recreate the cache when a pending notice is delivered after deletion", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + expect(cacheExists()).toBe(true); + + removeUpdateCache(); + expect(cacheExists()).toBe(false); + + const notice = takePendingUpdateNotice(); + expect(notice, "a notice was pending before the deletion").not.toBeNull(); + expect(cacheExists(), "delivery must not resurrect a deleted cache").toBe(false); + }); + + // The CLI is where the deletion is actually triggered. Spawning `logout` would + // touch real credential state, so assert the wiring statically instead: both + // credential-clearing paths must call the cache removal. + it("is wired into both CLI credential-clearing paths", () => { + const cli = readFileSync(new URL("./cli.ts", import.meta.url), "utf8"); + const calls = cli.match(/removeUpdateCache\(\)/g) ?? []; + expect(calls.length, "logout and auth --reset must both clear the cache").toBeGreaterThanOrEqual( + 2, + ); + expect(cli).toMatch(/removeUpdateCache/); + }); + it("persists no identifier and only the fields the feature needs", async () => { respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); diff --git a/src/update-check.ts b/src/update-check.ts index 1499d13..0ad3ec8 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -23,13 +23,20 @@ * WHERE THE DATA GOES (disclosure) * - The only endpoint contacted is the public npm registry, by default * `https://registry.npmjs.org`. That is a THIRD-PARTY service operated by npm, - * Inc. / GitHub and is **outside the Microsoft 365 and Azure compliance - * boundary**. It is not an M365 service and is not covered by the M365 data - * residency, EUDB, or tenant-data commitments. + * Inc. / GitHub. It is **not a Microsoft 365 or Azure Online Service**, so it + * is outside the Microsoft Product Terms, the Microsoft Products and Services + * Data Protection Addendum (DPA), and the EU Data Boundary — none of the M365 + * data-residency, EUDB, or tenant-data commitments apply to it. * - Making the connection at all inherently discloses to that third party the - * **client IP address**, the TLS/HTTP metadata of the connection, the requested - * **package name** (in the URL path), and — unless telemetry is opted out — the - * static product **`User-Agent`** string. Nothing else is sent. + * **client IP address**, standard TLS/HTTP connection metadata (TLS handshake + * and SNI, the `Host` and `Accept` headers, request time and timing), the + * requested **package name** (in the URL path), and the static product + * **`User-Agent`** string. Nothing else is sent. + * - Opting out of telemetry (`SPE_MCP_COLLECT_TELEMETRY=false`) suppresses the + * registry request **entirely** — it is a skip reason, so there is no + * connection and therefore nothing to disclose. (The shared user-agent helper + * also omits the product `User-Agent` when telemetry is off; for this endpoint + * that is defense in depth only, because no request is made at all.) * - No account, tenant, subscription, container, machine, install, session, or * content data is sent. There is no install GUID and no correlation identifier * of any kind, in the request or in the cache. @@ -472,8 +479,10 @@ function emitCollectionNotice(): void { * - no `authorization`, no `cookie`, and no identifier of any kind; * - `credentials: "omit"` and `redirect: "error"`, plus an explicit check that * the response did not come from a different host than the one we dialled; - * - the only headers are `accept` and — unless telemetry is opted out — the - * static product `User-Agent`. + * - the only headers are `accept` and the static product `User-Agent`. (When + * telemetry is opted out the check never reaches this function at all — the + * whole request is skipped — so there is no "request without a User-Agent" + * case for this endpoint.) */ async function fetchDistTags(url: string): Promise | null> { let expectedHost: string; diff --git a/src/update-notice-e2e.test.ts b/src/update-notice-e2e.test.ts new file mode 100644 index 0000000..0e78c3a --- /dev/null +++ b/src/update-notice-e2e.test.ts @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Spawned JSON-RPC regression for update-notice *delivery* (AB#3219517, SEC-008). + * + * The in-process suites prove the notice logic; this suite proves the property + * that only a real process boundary can show: the single announcement of a newer + * version survives a server that probes and then exits before any tool call. + * + * Shape of the run (three sequential servers, one shared data directory): + * 1. connect, then close without calling a tool => nothing is burned + * 2. restart, call a tool => exactly one notice + * 3. restart, call a tool => silence forever after + * + * Hermetic by construction: + * - the update cache is pre-seeded as a *fresh success*, so the server reuses it + * and performs no network request at all (registry.npmjs.org is never touched); + * - `SPE_DATA_DIR`, `HOME`, and `USERPROFILE` point at throwaway directories; + * - every CI marker and every opt-out variable is stripped from the child env, so + * the check actually runs (in CI it would otherwise be skipped); + * - only `content_access_grant` is called: a read-only, no-network, side-effect + * free tool whose unconfirmed result is a plain success. + * + * The server is spawned from a copy of `dist/` placed *under* `node_modules/`, + * because the check deliberately runs only for registry installs + * (`isInstalledFromRegistry()`); a checkout is skipped as `source-install`. The + * copy carries a sibling `package.json` so the runtime version lookup resolves. + */ + +import { execSync } from "node:child_process"; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { parseSemver, releaseChannel } from "./semver.js"; +import { ensureSecureDir, writeSecureFile } from "./secure-fs.js"; +import { DEFAULT_REGISTRY } from "./update-check.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, ".."); +const CLI_ENTRY = join(REPO_ROOT, "dist", "cli.js"); + +/** Copy of the built server placed under `node_modules/` so it looks installed. */ +const FIXTURE_DIR = join(REPO_ROOT, "node_modules", ".spe-update-e2e-fixture"); +const FIXTURE_CLI = join(FIXTURE_DIR, "dist", "cli.js"); + +const CALL_TIMEOUT_MS = 8000; + +/** Every marker the check treats as "running in CI" — all must be absent. */ +const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "TF_BUILD", "BUILD_BUILDID"]; + +/** Every documented kill switch — all must be absent for the check to run. */ +const OPT_OUT_ENV_VARS = [ + "SPE_MCP_UPDATE_CHECK", + "SPE_NO_UPDATE_CHECK", + "NO_UPDATE_NOTIFIER", + "SPE_MCP_COLLECT_TELEMETRY", + "SPE_NPM_REGISTRY", +]; + +const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as { + name: string; + version: string; +}; + +// Derive the seed from the *actual* running version so the suite keeps working +// across releases (stable builds have no channel tag; prereleases do). +const parsedCurrent = parseSemver(pkg.version); +const CHANNEL_TAG = parsedCurrent ? releaseChannel(parsedCurrent) : null; +const SEED_LATEST = "999.0.0"; +const SEED_CHANNEL_VERSION = CHANNEL_TAG ? `999.0.0-${CHANNEL_TAG}.1` : undefined; +/** Channel-first, exactly as a live check would resolve it. */ +const EXPECTED_LATEST = SEED_CHANNEL_VERSION ?? SEED_LATEST; + +let isolatedHome = ""; +let dataDir = ""; + +function cacheFile(): string { + return join(dataDir, "update-check.json"); +} + +/** Write a fresh, successful cache entry => the server reuses it, no network. */ +function seedCache(notifiedFor: string[] = []): void { + ensureSecureDir(dataDir); + writeSecureFile( + cacheFile(), + JSON.stringify( + { + version: 1, + checkedAt: Date.now(), + currentVersion: pkg.version, + registry: DEFAULT_REGISTRY, + outcome: "success", + latest: SEED_LATEST, + ...(CHANNEL_TAG ? { channelTag: CHANNEL_TAG, channelVersion: SEED_CHANNEL_VERSION } : {}), + notifiedFor, + }, + null, + 2, + ), + ); +} + +function readCacheFile(): { notifiedFor?: unknown } | null { + if (!existsSync(cacheFile())) return null; + return JSON.parse(readFileSync(cacheFile(), "utf8")) as { notifiedFor?: unknown }; +} + +function childEnv(): Record { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (typeof v === "string") env[k] = v; + } + for (const key of [ + ...CI_ENV_VARS, + ...OPT_OUT_ENV_VARS, + "SPE_CLIENT_ID", + "SPE_TENANT_ID", + "SPE_READ_ONLY", + "SPE_TOOLS", + ]) { + delete env[key]; + } + env.HOME = isolatedHome; + env.USERPROFILE = isolatedHome; + env.SPE_DATA_DIR = dataDir; + return env; +} + +async function startServer(): Promise<{ client: Client; transport: StdioClientTransport }> { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [FIXTURE_CLI, "start"], + env: childEnv(), + cwd: REPO_ROOT, + stderr: "ignore", + }); + const client = new Client({ name: "spe-mcp-update-e2e", version: "0.0.0" }, {}); + await client.connect(transport); + // The check is fire-and-forget; give the detached promise a moment to settle + // so the first tool call is guaranteed to see the pending notice. + await new Promise((r) => setTimeout(r, 400)); + return { client, transport }; +} + +async function stopServer(client?: Client, transport?: StdioClientTransport): Promise { + try { + await client?.close(); + } catch { + /* ignore */ + } + try { + await transport?.close(); + } catch { + /* ignore */ + } +} + +/** Call the safe, no-network tool and return its text + structured payload. */ +async function callSafeTool( + client: Client, +): Promise<{ text: string; structured: Record | undefined }> { + const res = await client.callTool({ name: "content_access_grant", arguments: {} }, undefined, { + timeout: CALL_TIMEOUT_MS, + }); + expect(res.isError, "content_access_grant should return a plain success").not.toBe(true); + const text = (res.content as Array<{ type: string; text: string }>).map((c) => c.text).join("\n"); + return { text, structured: res.structuredContent as Record | undefined }; +} + +describe("update notice delivery over spawned JSON-RPC (AB#3219517)", () => { + beforeAll(() => { + if (!existsSync(CLI_ENTRY)) { + execSync("npm run build", { cwd: REPO_ROOT, stdio: "ignore" }); + } + + // A copy under node_modules/ makes `isInstalledFromRegistry()` true without + // any production-code test hook. The sibling package.json is what the runtime + // version lookup reads. + rmSync(FIXTURE_DIR, { recursive: true, force: true }); + cpSync(join(REPO_ROOT, "dist"), join(FIXTURE_DIR, "dist"), { recursive: true }); + cpSync(join(REPO_ROOT, "package.json"), join(FIXTURE_DIR, "package.json")); + + isolatedHome = mkdtempSync(join(tmpdir(), "spe-mcp-update-home-")); + dataDir = mkdtempSync(join(tmpdir(), "spe-mcp-update-data-")); + }, 120000); + + afterAll(() => { + for (const dir of [FIXTURE_DIR, isolatedHome, dataDir]) { + try { + if (dir) rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + }); + + // (1) F1 regression: probing is not delivering. A server that exits before any + // tool call must not record the target as announced, or the user would + // never see the only notice they were ever going to get. + it("does not burn the notice when the server exits before any tool call", async () => { + seedCache([]); + + const { client, transport } = await startServer(); + await stopServer(client, transport); + + const cache = readCacheFile(); + expect(cache, "cache should survive the run").not.toBeNull(); + expect(cache?.notifiedFor, "probe-time persistence would have written the target here").toEqual( + [], + ); + }, 60000); + + // (2) The restarted server still owes the user a notice, and pays it exactly + // once — on the first *successful* tool result. + it("delivers exactly one notice on the first successful tool result after a restart", async () => { + const { client, transport } = await startServer(); + try { + const first = await callSafeTool(client); + expect(first.text, "first successful result should carry the notice").toMatch( + /Update available:/i, + ); + expect(first.text).toContain(EXPECTED_LATEST); + expect(first.text).toContain(pkg.version); + // F8: the structured twin is created even though this tool returns none. + const update = first.structured?.["updateAvailable"] as Record | undefined; + expect(update, "structuredContent.updateAvailable should be created").toBeDefined(); + expect(update?.["latest"]).toBe(EXPECTED_LATEST); + expect(update?.["current"]).toBe(pkg.version); + + const second = await callSafeTool(client); + expect(second.text, "the notice must not repeat within a session").not.toMatch( + /Update available:/i, + ); + expect(second.structured?.["updateAvailable"]).toBeUndefined(); + } finally { + await stopServer(client, transport); + } + + const cache = readCacheFile(); + expect( + cache?.notifiedFor, + "delivery should persist the announced target for future processes", + ).toEqual([EXPECTED_LATEST]); + }, 60000); + + // (3) Cross-process suppression: once delivered, never again for that target. + it("does not repeat the notice after a subsequent restart", async () => { + const { client, transport } = await startServer(); + try { + const result = await callSafeTool(client); + expect(result.text, "a delivered target must stay suppressed across restarts").not.toMatch( + /Update available:/i, + ); + expect(result.structured?.["updateAvailable"]).toBeUndefined(); + } finally { + await stopServer(client, transport); + } + + expect(readCacheFile()?.notifiedFor, "suppression list should not grow").toEqual([ + EXPECTED_LATEST, + ]); + }, 60000); +}); From eff815451191af113657e1dba8c524f19ac64cd8 Mon Sep 17 00:00:00 2001 From: grjoseph Date: Thu, 20 Aug 2026 21:16:47 -0700 Subject: [PATCH 4/9] chore(packaging): publish disclosure docs and make update guidance execution-neutral Addresses OSS package/release review feedback on the update-awareness change. - Update remediation is now execution-mode neutral. `UpdateAvailable` carries a `packageSpec` (and optional `stablePackageSpec`) instead of a bare global install command, and the rendered notice tells the operator to update or pin the package spec in their MCP client config, or reinstall the copy they actually launch. An unpinned `npx` launch may keep starting a cached build, so recommending only `npm install -g` was inaccurate for the documented default launch mode. `status_get` uses the same wording. Covered by a rendered-guidance unit test and refreshed README guidance. - Regenerated THIRD-PARTY-NOTICES from the locked production tree with `npm run notices`. The checked-in file was stale relative to package-lock.json; the regenerated output was verified byte-identical across two consecutive runs, so the generator is deterministic. A new offline consistency test compares each direct production dependency's version in the notices file against its resolved version in package-lock.json so a stale file fails CI. - Published disclosure documents. `package.json` `files` now ships CHANGELOG.md, NOTICE.md, PRIVACY.md, SECURITY.md, SUPPORT.md, docs/DATA-FLOW.md, docs/SECURITY-CONTROLS.md and docs/TROUBLESHOOTING.md alongside README.md, so the README's relative links resolve in an installed package and the privacy and security disclosures exist on disk. Packaging tests assert the allow-list, the files' presence, and that no .npmignore can override the allow-list; `npm pack --dry-run` confirms all of them are in the tarball. - Wording accuracy. "Anonymous" is replaced with "unauthenticated, without a user identifier" across the collection notice, README, PRIVACY.md and docs/DATA-FLOW.md, since the connection still discloses an IP address. The first-run stderr collection notice now names the registry actually contacted when SPE_NPM_REGISTRY is overridden; the value is still validated (HTTPS only, no credentials, no query or fragment, length capped) before it is echoed. No new runtime dependencies (still 6). Auto-update remains out of scope; stdout is never written to. Known, deliberately unaddressed here: server.json declares 0.1.0-alpha.1 while package.json declares 0.2.0-alpha.1. That mismatch predates this branch and is left for the rebase that follows the in-flight packaging work, to avoid an arbitrary version bump on a feature branch. AB#3219463 AB#3219517 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PRIVACY.md | 8 +- README.md | 30 ++++-- THIRD-PARTY-NOTICES | 202 ++++++++++++++++++++++----------------- docs/DATA-FLOW.md | 5 +- package.json | 10 +- src/packaging.test.ts | 75 +++++++++++++++ src/tools/status.ts | 2 +- src/update-check.test.ts | 108 +++++++++++++++++++-- src/update-check.ts | 109 ++++++++++++++------- 9 files changed, 405 insertions(+), 144 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 42c9241..705bbe1 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -13,7 +13,8 @@ organization's agreements with Microsoft. per-user data to Microsoft.** The only Microsoft-bound attribution signal is a static product `User-Agent` token, which is on by default and can be turned off (see [Turning it off](#turning-it-off)). The only destination that is **not a Microsoft 365 or Azure -Online Service** is an anonymous public-package lookup on the npm registry used to notify you of +Online Service** is an unauthenticated public-package lookup — sent without a user identifier — +on the npm registry used to notify you of newer releases, which can also be turned off. Specifically: - **No telemetry channel.** The tool does not implement application telemetry and does not @@ -55,8 +56,9 @@ newer releases, which can also be turned off. Specifically: GET https://registry.npmjs.org/@microsoft%2fspe-mcp ``` - **What the third party can see.** The request is an **anonymous, unauthenticated HTTP GET of - public package metadata** — the same lookup `npm view` performs. The request body and headers + **What the third party can see.** The request is an **unauthenticated HTTP GET of + public package metadata, sent without a user identifier** — the same lookup `npm view` performs. + The request body and headers carry no identifiers, but the connection itself necessarily discloses to npm: | Disclosed to npm | Why | diff --git a/README.md b/README.md index f0e8495..09842c8 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,18 @@ Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or ### Updating / removing -Because clients run the package through `npx`, they pick up published updates -without a global install. Pin a specific version with -`@microsoft/spe-mcp@0.1.0-alpha.1`. To remove the server, delete the MCP -client config entry. +How you update depends on how your MCP client launches the server, so update the +copy the client actually runs: + +- **Unpinned `npx -y @microsoft/spe-mcp` (the config above).** `npx` may keep + starting a cached build, so pin the package spec in the client config to the + version or channel you want — for example `@microsoft/spe-mcp@alpha` or + `@microsoft/spe-mcp@0.2.0-alpha.1` — and restart the client. +- **Global install.** Reinstall it: `npm install -g @microsoft/spe-mcp@alpha`. +- **Project-local install.** Update the dependency in that project and reinstall. + +To remove the server, delete the MCP client config entry (and uninstall the +package if you installed it globally or locally). ### Update notifications @@ -130,7 +138,8 @@ release and — if one exists — appends a short notice to a single tool result ```text Update available: @microsoft/spe-mcp 0.2.0-alpha.1 -> 0.2.0-alpha.4 (alpha channel). -Update with: npm install -g @microsoft/spe-mcp@alpha +To update, point your MCP client at @microsoft/spe-mcp@alpha — update or pin the package spec in the client config (for example the npx args), or reinstall the copy you actually launch (for example npm install -g @microsoft/spe-mcp@alpha for a global install). An unpinned npx launch may keep starting a cached build. +Nothing was downloaded or installed; this is a notification only. Disable this check with --no-update-check or SPE_MCP_UPDATE_CHECK=false. ``` The current version and the update state are also reported by `status_get`, so @@ -145,8 +154,9 @@ How it behaves: - **Channel-aware.** A prerelease install (e.g. `alpha`) is compared against its own dist-tag, and a newer **stable** release is mentioned separately. - **Quiet.** The notice is shown once per newer version, not on every call. -- **Anonymous.** Exactly one unauthenticated `GET` of the package's public - metadata — `https://registry.npmjs.org/@microsoft%2fspe-mcp`, no query string, +- **Unauthenticated, without a user identifier.** Exactly one unauthenticated + `GET` of the package's public metadata — + `https://registry.npmjs.org/@microsoft%2fspe-mcp`, no query string, redirects rejected. No credentials, cookies, `.npmrc`, or `npm` subprocess are involved, and **no install GUID, machine, user, tenant, subscription, or session identifier** is sent. As with any HTTPS request, npm sees your IP @@ -156,7 +166,9 @@ How it behaves: (`SPE_MCP_COLLECT_TELEMETRY=false`) suppresses the request entirely, so none of that is disclosed. - **Announced.** Before the first check in a process, a one-time notice is - printed to **stderr** naming the endpoint, the boundary, and the opt-out. + printed to **stderr** naming the endpoint actually contacted (the registry from + `SPE_NPM_REGISTRY` if you set one, otherwise `registry.npmjs.org`), the + boundary, and the opt-out. - **Cached locally.** The result is stored owner-only at `/update-check.json` and **kept until you delete it**; `spe-mcp logout` and `spe-mcp auth --reset` remove it, and `status_get` prints @@ -680,7 +692,7 @@ day by the [update check](#update-notifications) to read the published version l `@microsoft/spe-mcp`. ⚠️ Because it is not a Microsoft Online Service, it is **not covered by the Microsoft Product Terms, the Microsoft Products and Services Data Protection Addendum (DPA), or the EU Data Boundary**, and it is **outside the Microsoft 365 / Azure compliance -boundary**. The request is **unauthenticated and anonymous** — exactly +boundary**. The request is **unauthenticated and carries no user identifier** — exactly `GET https://registry.npmjs.org/@microsoft%2fspe-mcp` with no query string, no credentials or cookies, redirects rejected, and **no install GUID, machine, user, tenant, subscription, correlation, or session identifier**; it is an ordinary public package lookup, identical to diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 9c9b0ca..3007a94 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -21,7 +21,7 @@ licenses. It is generated by scripts/generate-third-party-notices.mjs. --------------------------------------------------------------- -1. @azure/msal-common 14.16.1 (MIT) +1. @azure/msal-common 16.11.3 (MIT) https://github.com/AzureAD/microsoft-authentication-library-for-js MIT License @@ -48,7 +48,7 @@ SOFTWARE --------------------------------------------------------------- -2. @azure/msal-node 2.16.3 (MIT) +2. @azure/msal-node 5.4.3 (MIT) https://github.com/AzureAD/microsoft-authentication-library-for-js MIT License @@ -75,7 +75,7 @@ SOFTWARE. --------------------------------------------------------------- -3. @hono/node-server 1.19.14 (MIT) +3. @hono/node-server 2.0.12 (MIT) https://github.com/honojs/node-server MIT License @@ -102,7 +102,7 @@ SOFTWARE. --------------------------------------------------------------- -4. @modelcontextprotocol/sdk 1.29.0 (MIT) +4. @modelcontextprotocol/sdk 1.30.0 (MIT) https://github.com/modelcontextprotocol/typescript-sdk MIT License @@ -357,7 +357,7 @@ SOFTWARE. --------------------------------------------------------------- -14. commander 12.1.0 (MIT) +14. commander 15.0.0 (MIT) https://github.com/tj/commander.js (The MIT License) @@ -1245,7 +1245,7 @@ SOFTWARE. --------------------------------------------------------------- -41. fast-uri 3.1.2 (BSD-3-Clause) +41. fast-uri 3.1.5 (BSD-3-Clause) https://github.com/fastify/fast-uri Copyright (c) 2011-2021, Gary Court until https://github.com/garycourt/uri-js/commit/a1acf730b4bba3f1097c9f52e7d9d3aba8cdcaae @@ -1526,7 +1526,7 @@ SOFTWARE. --------------------------------------------------------------- -51. hono 4.12.26 (MIT) +51. hono 4.13.0 (MIT) https://github.com/honojs/hono MIT License @@ -1628,8 +1628,8 @@ PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------------- -55. ip-address 10.2.0 (MIT) -git://github.com/beaugunderson/ip-address +55. ip-address 10.4.0 (MIT) +https://github.com/beaugunderson/ip-address Copyright (C) 2011 by Beau Gunderson @@ -1693,7 +1693,22 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -58. is-inside-container 1.0.0 (MIT) +58. is-in-ssh 1.0.0 (MIT) +sindresorhus/is-in-ssh + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +59. is-inside-container 1.0.0 (MIT) sindresorhus/is-inside-container MIT License @@ -1708,7 +1723,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -59. is-promise 4.0.0 (MIT) +60. is-promise 4.0.0 (MIT) https://github.com/then/is-promise Copyright (c) 2014 Forbes Lindesay @@ -1733,7 +1748,7 @@ THE SOFTWARE. --------------------------------------------------------------- -60. is-wsl 3.1.1 (MIT) +61. is-wsl 3.1.1 (MIT) sindresorhus/is-wsl MIT License @@ -1748,7 +1763,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -61. isexe 2.0.0 (ISC) +62. isexe 2.0.0 (ISC) https://github.com/isaacs/isexe The ISC License @@ -1769,7 +1784,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------------- -62. jose 6.2.3 (MIT) +63. jose 6.2.3 (MIT) panva/jose The MIT License (MIT) @@ -1796,7 +1811,7 @@ SOFTWARE. --------------------------------------------------------------- -63. json-schema-traverse 1.0.0 (MIT) +64. json-schema-traverse 1.0.0 (MIT) https://github.com/epoberezkin/json-schema-traverse MIT License @@ -1823,7 +1838,7 @@ SOFTWARE. --------------------------------------------------------------- -64. json-schema-typed 8.0.2 (BSD-2-Clause) +65. json-schema-typed 8.0.2 (BSD-2-Clause) https://github.com/RemyRylan/json-schema-typed BSD 2-Clause License @@ -1886,7 +1901,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --------------------------------------------------------------- -65. jsonwebtoken 9.0.3 (MIT) +66. jsonwebtoken 9.0.3 (MIT) https://github.com/auth0/node-jsonwebtoken The MIT License (MIT) @@ -1913,7 +1928,7 @@ SOFTWARE. --------------------------------------------------------------- -66. jwa 2.0.1 (MIT) +67. jwa 2.0.1 (MIT) git://github.com/brianloveswords/node-jwa Copyright (c) 2013 Brian J. Brennan @@ -1936,7 +1951,7 @@ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEAL --------------------------------------------------------------- -67. jws 4.0.1 (MIT) +68. jws 4.0.1 (MIT) git://github.com/brianloveswords/node-jws Copyright (c) 2013 Brian J. Brennan @@ -1959,7 +1974,7 @@ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEAL --------------------------------------------------------------- -68. lodash.includes 4.3.0 (MIT) +69. lodash.includes 4.3.0 (MIT) lodash/lodash Copyright jQuery Foundation and other contributors @@ -2012,7 +2027,7 @@ terms above. --------------------------------------------------------------- -69. lodash.isboolean 3.0.3 (MIT) +70. lodash.isboolean 3.0.3 (MIT) lodash/lodash Copyright 2012-2016 The Dojo Foundation @@ -2040,7 +2055,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -70. lodash.isinteger 4.0.4 (MIT) +71. lodash.isinteger 4.0.4 (MIT) lodash/lodash Copyright jQuery Foundation and other contributors @@ -2093,7 +2108,7 @@ terms above. --------------------------------------------------------------- -71. lodash.isnumber 3.0.3 (MIT) +72. lodash.isnumber 3.0.3 (MIT) lodash/lodash Copyright 2012-2016 The Dojo Foundation @@ -2121,7 +2136,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -72. lodash.isplainobject 4.0.6 (MIT) +73. lodash.isplainobject 4.0.6 (MIT) lodash/lodash Copyright jQuery Foundation and other contributors @@ -2174,7 +2189,7 @@ terms above. --------------------------------------------------------------- -73. lodash.isstring 4.0.1 (MIT) +74. lodash.isstring 4.0.1 (MIT) lodash/lodash Copyright 2012-2016 The Dojo Foundation @@ -2202,7 +2217,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -74. lodash.once 4.1.1 (MIT) +75. lodash.once 4.1.1 (MIT) lodash/lodash Copyright jQuery Foundation and other contributors @@ -2255,7 +2270,7 @@ terms above. --------------------------------------------------------------- -75. math-intrinsics 1.1.0 (MIT) +76. math-intrinsics 1.1.0 (MIT) https://github.com/es-shims/math-intrinsics MIT License @@ -2282,7 +2297,7 @@ SOFTWARE. --------------------------------------------------------------- -76. media-typer 1.1.0 (MIT) +77. media-typer 1.1.0 (MIT) jshttp/media-typer (The MIT License) @@ -2310,7 +2325,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -77. merge-descriptors 2.0.0 (MIT) +78. merge-descriptors 2.0.0 (MIT) sindresorhus/merge-descriptors MIT License @@ -2327,7 +2342,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -78. mime-db 1.54.0 (MIT) +79. mime-db 1.54.0 (MIT) jshttp/mime-db (The MIT License) @@ -2356,7 +2371,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -79. mime-types 3.0.2 (MIT) +80. mime-types 3.0.2 (MIT) jshttp/mime-types (The MIT License) @@ -2385,7 +2400,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -80. ms 2.1.3 (MIT) +81. ms 2.1.3 (MIT) vercel/ms The MIT License (MIT) @@ -2412,7 +2427,7 @@ SOFTWARE. --------------------------------------------------------------- -81. negotiator 1.0.0 (MIT) +82. negotiator 1.0.0 (MIT) jshttp/negotiator (The MIT License) @@ -2442,7 +2457,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -82. object-assign 4.1.1 (MIT) +83. object-assign 4.1.1 (MIT) sindresorhus/object-assign The MIT License (MIT) @@ -2469,7 +2484,7 @@ THE SOFTWARE. --------------------------------------------------------------- -83. object-inspect 1.13.4 (MIT) +84. object-inspect 1.13.4 (MIT) git://github.com/inspect-js/object-inspect MIT License @@ -2496,7 +2511,7 @@ SOFTWARE. --------------------------------------------------------------- -84. on-finished 2.4.1 (MIT) +85. on-finished 2.4.1 (MIT) jshttp/on-finished (The MIT License) @@ -2525,7 +2540,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -85. once 1.4.0 (ISC) +86. once 1.4.0 (ISC) git://github.com/isaacs/once The ISC License @@ -2546,7 +2561,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------------- -86. open 10.2.0 (MIT) +87. open 11.0.1 (MIT) sindresorhus/open MIT License @@ -2561,7 +2576,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -87. parseurl 1.3.3 (MIT) +88. parseurl 1.3.3 (MIT) pillarjs/parseurl (The MIT License) @@ -2590,7 +2605,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -88. path-key 3.1.1 (MIT) +89. path-key 3.1.1 (MIT) sindresorhus/path-key MIT License @@ -2605,7 +2620,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -89. path-to-regexp 8.4.2 (MIT) +90. path-to-regexp 8.4.2 (MIT) https://github.com/pillarjs/path-to-regexp The MIT License (MIT) @@ -2632,7 +2647,7 @@ THE SOFTWARE. --------------------------------------------------------------- -90. pkce-challenge 5.0.1 (MIT) +91. pkce-challenge 5.0.1 (MIT) https://github.com/crouchcd/pkce-challenge MIT License @@ -2659,7 +2674,37 @@ SOFTWARE. --------------------------------------------------------------- -91. proxy-addr 2.0.7 (MIT) +92. powershell-utils 0.1.0 (MIT) +sindresorhus/powershell-utils + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +93. powershell-utils 0.2.0 (MIT) +sindresorhus/powershell-utils + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--------------------------------------------------------------- + +94. proxy-addr 2.0.7 (MIT) jshttp/proxy-addr (The MIT License) @@ -2687,7 +2732,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -92. qs 6.15.2 (BSD-3-Clause) +95. qs 6.15.2 (BSD-3-Clause) https://github.com/ljharb/qs BSD 3-Clause License @@ -2722,7 +2767,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --------------------------------------------------------------- -93. range-parser 1.2.1 (MIT) +96. range-parser 1.2.1 (MIT) jshttp/range-parser (The MIT License) @@ -2751,7 +2796,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -94. raw-body 3.0.2 (MIT) +97. raw-body 3.0.2 (MIT) stream-utils/raw-body The MIT License (MIT) @@ -2779,7 +2824,7 @@ THE SOFTWARE. --------------------------------------------------------------- -95. require-from-string 2.0.2 (MIT) +98. require-from-string 2.0.2 (MIT) floatdrop/require-from-string The MIT License (MIT) @@ -2806,7 +2851,7 @@ THE SOFTWARE. --------------------------------------------------------------- -96. router 2.2.0 (MIT) +99. router 2.2.0 (MIT) pillarjs/router (The MIT License) @@ -2835,7 +2880,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -97. run-applescript 7.1.0 (MIT) +100. run-applescript 7.1.0 (MIT) sindresorhus/run-applescript MIT License @@ -2850,7 +2895,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -98. safe-buffer 5.2.1 (MIT) +101. safe-buffer 5.2.1 (MIT) git://github.com/feross/safe-buffer The MIT License (MIT) @@ -2877,7 +2922,7 @@ THE SOFTWARE. --------------------------------------------------------------- -99. safer-buffer 2.1.2 (MIT) +102. safer-buffer 2.1.2 (MIT) https://github.com/ChALkeR/safer-buffer MIT License @@ -2904,7 +2949,7 @@ SOFTWARE. --------------------------------------------------------------- -100. semver 7.8.4 (ISC) +103. semver 7.8.4 (ISC) https://github.com/npm/node-semver The ISC License @@ -2925,7 +2970,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------------- -101. send 1.2.1 (MIT) +104. send 1.2.1 (MIT) pillarjs/send (The MIT License) @@ -2954,7 +2999,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -102. serve-static 2.2.1 (MIT) +105. serve-static 2.2.1 (MIT) expressjs/serve-static (The MIT License) @@ -2985,7 +3030,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -103. setprototypeof 1.2.0 (ISC) +106. setprototypeof 1.2.0 (ISC) https://github.com/wesleytodd/setprototypeof Copyright (c) 2015, Wes Todd @@ -3004,7 +3049,7 @@ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------------- -104. shebang-command 2.0.0 (MIT) +107. shebang-command 2.0.0 (MIT) kevva/shebang-command MIT License @@ -3019,7 +3064,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -105. shebang-regex 3.0.0 (MIT) +108. shebang-regex 3.0.0 (MIT) sindresorhus/shebang-regex MIT License @@ -3034,7 +3079,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -106. side-channel 1.1.1 (MIT) +109. side-channel 1.1.1 (MIT) https://github.com/ljharb/side-channel MIT License @@ -3061,7 +3106,7 @@ SOFTWARE. --------------------------------------------------------------- -107. side-channel-list 1.0.1 (MIT) +110. side-channel-list 1.0.1 (MIT) https://github.com/ljharb/side-channel-list MIT License @@ -3088,7 +3133,7 @@ SOFTWARE. --------------------------------------------------------------- -108. side-channel-map 1.0.1 (MIT) +111. side-channel-map 1.0.1 (MIT) https://github.com/ljharb/side-channel-map MIT License @@ -3115,7 +3160,7 @@ SOFTWARE. --------------------------------------------------------------- -109. side-channel-weakmap 1.0.2 (MIT) +112. side-channel-weakmap 1.0.2 (MIT) https://github.com/ljharb/side-channel-weakmap MIT License @@ -3142,7 +3187,7 @@ SOFTWARE. --------------------------------------------------------------- -110. statuses 2.0.2 (MIT) +113. statuses 2.0.2 (MIT) jshttp/statuses The MIT License (MIT) @@ -3170,7 +3215,7 @@ THE SOFTWARE. --------------------------------------------------------------- -111. toidentifier 1.0.1 (MIT) +114. toidentifier 1.0.1 (MIT) component/toidentifier MIT License @@ -3197,7 +3242,7 @@ SOFTWARE. --------------------------------------------------------------- -112. type-is 2.1.0 (MIT) +115. type-is 2.1.0 (MIT) jshttp/type-is (The MIT License) @@ -3226,7 +3271,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -113. unpipe 1.0.0 (MIT) +116. unpipe 1.0.0 (MIT) stream-utils/unpipe (The MIT License) @@ -3254,22 +3299,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -114. uuid 11.1.1 (MIT) -https://github.com/uuidjs/uuid - -The MIT License (MIT) - -Copyright (c) 2010-2020 Robert Kieffer and other contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------------- - -115. vary 1.1.2 (MIT) +117. vary 1.1.2 (MIT) jshttp/vary (The MIT License) @@ -3297,7 +3327,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------- -116. which 2.0.2 (ISC) +118. which 2.0.2 (ISC) git://github.com/isaacs/node-which The ISC License @@ -3318,7 +3348,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------------- -117. wrappy 1.0.2 (ISC) +119. wrappy 1.0.2 (ISC) https://github.com/npm/wrappy The ISC License @@ -3339,7 +3369,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------------- -118. wsl-utils 0.1.0 (MIT) +120. wsl-utils 1.0.0 (MIT) sindresorhus/wsl-utils MIT License @@ -3354,7 +3384,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------------- -119. zod 4.4.3 (MIT) +121. zod 4.4.3 (MIT) https://github.com/colinhacks/zod MIT License @@ -3381,7 +3411,7 @@ SOFTWARE. --------------------------------------------------------------- -120. zod-to-json-schema 3.25.2 (ISC) +122. zod-to-json-schema 3.25.2 (ISC) https://github.com/StefanTerdell/zod-to-json-schema ISC License diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 8abc02d..0a2055d 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -15,8 +15,9 @@ MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft network socket for the client connection. - Every outbound network call is HTTPS. All calls that carry your data go to a **Microsoft-operated** endpoint, made **on your behalf**, using **your** credentials, into - **your** tenant and subscription. The single exception is an anonymous public-package - lookup on the npm registry (below), which carries no data of yours. + **your** tenant and subscription. The single exception is an unauthenticated public-package + lookup on the npm registry (below), sent without a user identifier, which carries no data of + yours. ## Outbound endpoints diff --git a/package.json b/package.json index 7db0ffd..35ea403 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,16 @@ "files": [ "dist", "samples", - "README.md", + "docs/DATA-FLOW.md", + "docs/SECURITY-CONTROLS.md", + "docs/TROUBLESHOOTING.md", + "CHANGELOG.md", "LICENSE", + "NOTICE.md", + "PRIVACY.md", + "README.md", + "SECURITY.md", + "SUPPORT.md", "THIRD-PARTY-NOTICES" ], "scripts": { diff --git a/src/packaging.test.ts b/src/packaging.test.ts index 0031c88..c01108a 100644 --- a/src/packaging.test.ts +++ b/src/packaging.test.ts @@ -71,6 +71,81 @@ describe("packaging: THIRD-PARTY-NOTICES", () => { expect(notices, `missing attribution for ${dep}`).toContain(dep); } }); + + /** + * Consistency guard: the checked-in notices file must match the *locked* + * production tree, not a historical one. `npm run notices` emits one numbered + * entry per package as `N. ()`; this compares the + * version in that entry against the version `package-lock.json` resolves for + * the same direct dependency. It is offline and deterministic (it never runs + * the generator), so a stale THIRD-PARTY-NOTICES fails CI instead of shipping. + */ + it("records the locked version of every direct production dependency", () => { + const notices = readFileSync(noticesPath, "utf8"); + const lock = readJson("package-lock.json"); + const packages = (lock.packages ?? {}) as Record; + + const mismatches: string[] = []; + for (const dep of Object.keys(pkg.dependencies ?? {})) { + const locked = packages[`node_modules/${dep}`]?.version; + expect(locked, `no lockfile entry for ${dep}`).toBeTruthy(); + + const escaped = dep.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + const entry = new RegExp(`^\\d+\\. ${escaped} (\\S+) \\(`, "m").exec(notices); + if (!entry) { + mismatches.push(`${dep}: no numbered notices entry`); + continue; + } + if (entry[1] !== locked) { + mismatches.push(`${dep}: notices ${entry[1]} != lockfile ${locked}`); + } + } + + expect( + mismatches, + `THIRD-PARTY-NOTICES is stale; run \`npm run notices\`: ${mismatches.join("; ")}`, + ).toHaveLength(0); + }); +}); + +/** + * B3 (OSS review): the published tarball must carry the disclosure documents the + * README links to. README ships in the package and links to PRIVACY.md, + * docs/DATA-FLOW.md, docs/SECURITY-CONTROLS.md and friends with *relative* + * links, so omitting them from `files` leaves an installed copy with dead links + * and no on-disk privacy/security disclosure. + */ +describe("packaging: disclosure documents are published", () => { + const DISCLOSURE_DOCS = [ + "CHANGELOG.md", + "NOTICE.md", + "PRIVACY.md", + "README.md", + "SECURITY.md", + "SUPPORT.md", + "docs/DATA-FLOW.md", + "docs/SECURITY-CONTROLS.md", + "docs/TROUBLESHOOTING.md", + ]; + + it("lists every disclosure document in the published files allow-list", () => { + const files = (pkg.files ?? []) as string[]; + for (const doc of DISCLOSURE_DOCS) { + expect(files, `${doc} must be published`).toContain(doc); + } + }); + + it("has every listed disclosure document on disk", () => { + for (const doc of DISCLOSURE_DOCS) { + expect(existsSync(join(pkgRoot, doc)), `${doc} is missing from the repo`).toBe(true); + } + }); + + it("ships no .npmignore that could override the files allow-list", () => { + // `.npmignore` takes precedence over `files` for directory contents; its + // absence is what makes the allow-list above authoritative. + expect(existsSync(join(pkgRoot, ".npmignore"))).toBe(false); + }); }); describe("packaging: complete metadata", () => { diff --git a/src/tools/status.ts b/src/tools/status.ts index a056d43..9da9926 100644 --- a/src/tools/status.ts +++ b/src/tools/status.ts @@ -40,7 +40,7 @@ function versionRows(): string { updateCell = "✅ up to date"; break; case "update-available": - updateCell = `⬆️ ${status.latestVersion ?? "newer version"} available — \`${status.updateAvailable?.command ?? ""}\``; + updateCell = `⬆️ ${status.latestVersion ?? "newer version"} available — update the package spec your MCP client launches to \`${status.updateAvailable?.packageSpec ?? ""}\``; break; default: updateCell = "— unavailable (registry not reachable)"; diff --git a/src/update-check.test.ts b/src/update-check.test.ts index 971bbd9..2c44717 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -17,6 +17,7 @@ import { join } from "node:path"; import { COLLECTION_NOTICE, + collectionNotice, DEFAULT_REGISTRY, CHECK_TTL_MS, FAILURE_BACKOFF_MS, @@ -457,21 +458,44 @@ describe("cache", () => { // --------------------------------------------------------------------------- describe("renderNotice", () => { - it("names the channel and the exact install command", () => { + it("names the channel and the package spec to move to", () => { const text = __testing.renderNotice({ package: PACKAGE_NAME, current: "1.0.0-alpha.1", latest: "1.0.0-alpha.2", channel: "alpha", - command: `npm install -g ${PACKAGE_NAME}@alpha`, + packageSpec: `${PACKAGE_NAME}@alpha`, }); expect(text).toContain("1.0.0-alpha.1 -> 1.0.0-alpha.2"); expect(text).toContain("(alpha channel)"); - expect(text).toContain(`npm install -g ${PACKAGE_NAME}@alpha`); + expect(text).toContain(`${PACKAGE_NAME}@alpha`); expect(text).toContain("--no-update-check"); expect(text).not.toContain("Latest stable release"); }); + // B1: the server is usually launched by an MCP client through an unpinned + // `npx -y @microsoft/spe-mcp`. Guidance that names ONLY a global install would + // tell the user to update something the client never runs. + it("gives execution-mode neutral remediation, not a bare global install", () => { + const text = __testing.renderNotice({ + package: PACKAGE_NAME, + current: "1.0.0-alpha.1", + latest: "1.0.0-alpha.2", + channel: "alpha", + packageSpec: `${PACKAGE_NAME}@alpha`, + }); + // Points at the client configuration first... + expect(text).toContain("MCP client"); + expect(text).toMatch(/package spec/i); + // ...warns about the unpinned npx caching trap... + expect(text).toMatch(/npx/i); + expect(text).toMatch(/cached build/i); + // ...and offers the global install only as one example among modes. + expect(text).toContain("for example npm install -g"); + // Still explicitly notify-only. + expect(text).toContain("Nothing was downloaded or installed"); + }); + it("calls out a separate stable target when one exists", () => { const text = __testing.renderNotice({ package: PACKAGE_NAME, @@ -479,9 +503,11 @@ describe("renderNotice", () => { latest: "1.0.0-alpha.2", channel: "alpha", stable: "2.0.0", - command: `npm install -g ${PACKAGE_NAME}@alpha`, + packageSpec: `${PACKAGE_NAME}@alpha`, + stablePackageSpec: `${PACKAGE_NAME}@latest`, }); expect(text).toContain("Latest stable release: 2.0.0"); + expect(text).toContain(`spec ${PACKAGE_NAME}@latest`); }); it("omits the channel clause for a stable build", () => { @@ -490,10 +516,10 @@ describe("renderNotice", () => { current: "1.0.0", latest: "1.1.0", channel: null, - command: `npm install -g ${PACKAGE_NAME}@latest`, + packageSpec: `${PACKAGE_NAME}@latest`, }); expect(text).not.toContain("channel)"); - expect(text).toContain(`npm install -g ${PACKAGE_NAME}@latest`); + expect(text).toContain(`${PACKAGE_NAME}@latest`); }); }); @@ -512,7 +538,7 @@ describe("runUpdateCheck", () => { expect(status.state).toBe("update-available"); expect(status.latestVersion).toBe(EXPECTED_LATEST); expect(status.currentVersion).toBe(PACKAGE_VERSION); - expect(status.updateAvailable?.command).toContain(`npm install -g ${PACKAGE_NAME}@`); + expect(status.updateAvailable?.packageSpec).toBe(`${PACKAGE_NAME}@alpha`); const notice = takePendingUpdateNotice(); expect(notice?.text).toContain(EXPECTED_LATEST); @@ -1068,6 +1094,74 @@ describe("privacy: first-run collection notice", () => { }); }); +// --------------------------------------------------------------------------- +// B4: the disclosure must name the registry that is actually contacted. +// --------------------------------------------------------------------------- + +describe("privacy: collection notice names the configured registry", () => { + let stderr: ReturnType; + let lines: string[]; + + beforeEach(() => { + lines = []; + stderr = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + respondWith(packument(tagsFixture())); + }); + + afterEach(() => { + stderr.mockRestore(); + }); + + it("names the default public registry when no override is set", async () => { + await __testing.runUpdateCheck({}); + const notice = lines.find((l) => l.includes("OUTSIDE the Microsoft 365 / Azure")); + expect(notice).toBeDefined(); + expect(notice).toContain(DEFAULT_REGISTRY); + }); + + it("names the override host, not the default, when SPE_NPM_REGISTRY is set", async () => { + process.env.SPE_NPM_REGISTRY = "https://npm.contoso.example"; + await __testing.runUpdateCheck({}); + const notice = lines.find((l) => l.includes("OUTSIDE the Microsoft 365 / Azure")); + expect(notice).toBeDefined(); + expect(notice).toContain("https://npm.contoso.example"); + // Telling the user we contacted npmjs.org when we did not would be a + // false disclosure. + expect(notice).not.toContain(DEFAULT_REGISTRY); + }); + + it("only ever renders a registry that already passed validation", () => { + // collectionNotice() prints its argument verbatim, so the sanitisation + // guarantee lives in resolveRegistry(): anything it accepts is an https + // origin with no credentials, query, or fragment. + for (const hostile of [ + "http://npm.example", + "https://user:pw@npm.example", + "https://npm.example/?x=1", + "https://npm.example/#f", + `https://npm.example/${"a".repeat(600)}`, + ]) { + process.env.SPE_NPM_REGISTRY = hostile; + expect(__testing.resolveRegistry()).toBeNull(); + } + }); + + it("keeps the exported default disclosure in sync with the builder", () => { + expect(COLLECTION_NOTICE).toBe(collectionNotice(DEFAULT_REGISTRY)); + expect(COLLECTION_NOTICE).toContain(DEFAULT_REGISTRY); + }); + + it("states the request is unauthenticated and carries no user identifier", () => { + // B4: "anonymous" overstates the guarantee — an IP address is still + // observable. Say precisely what is and is not sent. + expect(COLLECTION_NOTICE).toContain("unauthenticated"); + expect(COLLECTION_NOTICE).toContain("no user"); + expect(COLLECTION_NOTICE).not.toContain("anonymous"); + }); +}); + describe("privacy: cache retention and deletion", () => { it("removes the cache file, mirroring logout", async () => { respondWith(packument(tagsFixture())); diff --git a/src/update-check.ts b/src/update-check.ts index 0ad3ec8..ee6a35b 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -41,9 +41,9 @@ * content data is sent. There is no install GUID and no correlation identifier * of any kind, in the request or in the cache. * - Before the FIRST network request of a process, a one-time collection notice - * ({@link COLLECTION_NOTICE}) is written to stderr naming the endpoint, its - * boundary status, and the opt-out. Skipped/cached runs make no request and so - * emit no notice. + * ({@link collectionNotice}) is written to stderr naming the endpoint actually + * contacted (including a `SPE_NPM_REGISTRY` override), its boundary status, and + * the opt-out. Skipped/cached runs make no request and so emit no notice. * - The result is cached on the local disk only. It is **retained until deleted** * — by `spe-mcp logout`, by removing the data dir, or by deleting the file * reported by `status_get`. There is no server-side record and no retention @@ -151,24 +151,37 @@ const CI_ENV_VARS = [ const logger = createLogger("Update"); /** - * One-time stderr disclosure emitted immediately BEFORE the first network - * request of a process. Exported so docs and tests assert the exact wording. + * Build the one-time stderr disclosure emitted immediately BEFORE the first + * network request of a process. * - * It must name the endpoint, say plainly that the endpoint sits outside the - * Microsoft 365 / Azure compliance boundary, say what the connection discloses, - * say that nothing is installed, and name the opt-out. + * It must name the endpoint actually contacted, say plainly that the endpoint + * sits outside the Microsoft 365 / Azure compliance boundary, say what the + * connection discloses, say that nothing is installed, and name the opt-out. + * + * @param registry - The resolved registry origin. Always a value that already + * passed {@link resolveRegistry} (HTTPS, no credentials, no query/fragment, + * length-capped), so it is safe to print verbatim. + */ +export function collectionNotice(registry: string): string { + return [ + `Update check: contacting the npm registry at ${registry} to see whether a`, + `newer version of ${PACKAGE_NAME} has been published.`, + "The npm registry is a third-party service OUTSIDE the Microsoft 365 / Azure", + "compliance boundary. The request is unauthenticated and carries no user", + "identifier and no account, tenant, machine, session, or content data — but", + "the connection itself discloses your IP address, the package name, and the", + "product User-Agent to that third party. The result is cached locally until", + "you delete it. Nothing is downloaded, installed, or updated automatically.", + "Turn this off with --no-update-check or SPE_MCP_UPDATE_CHECK=false.", + ].join(" "); +} + +/** + * The disclosure for the default public registry. Exported so docs and tests + * assert the exact wording; a `SPE_NPM_REGISTRY` override names that host + * instead (see {@link collectionNotice}). */ -export const COLLECTION_NOTICE = [ - "Update check: contacting the public npm registry to see whether a newer", - `version of ${PACKAGE_NAME} has been published.`, - "The npm registry is a third-party service OUTSIDE the Microsoft 365 / Azure", - "compliance boundary. The request is unauthenticated and sends no account,", - "tenant, machine, session, or content data — but the connection itself", - "discloses your IP address, the package name, and the product User-Agent to", - "that third party. The result is cached locally until you delete it.", - "Nothing is downloaded, installed, or updated automatically.", - "Turn this off with --no-update-check or SPE_MCP_UPDATE_CHECK=false.", -].join(" "); +export const COLLECTION_NOTICE = collectionNotice(DEFAULT_REGISTRY); /** Why the check did not run. */ export type UpdateSkipReason = @@ -194,8 +207,15 @@ export interface UpdateAvailable { readonly channel: string | null; /** Newest STABLE release, when it is also newer than the running build. */ readonly stable?: string; - /** The exact command a user can run to update. Informational only. */ - readonly command: string; + /** + * The package spec to move to, e.g. `@microsoft/spe-mcp@alpha`. Deliberately + * NOT an install command: the server may be launched by `npx`, by a global + * install, or from a pinned spec in an MCP client config, and only the user + * knows which. Informational only — nothing is installed automatically. + */ + readonly packageSpec: string; + /** Package spec for the newest stable release, when {@link stable} is set. */ + readonly stablePackageSpec?: string; } /** A ready-to-append notice plus its structured twin. */ @@ -457,16 +477,20 @@ function extractDistTags(raw: string): Record { } /** - * Emit the {@link COLLECTION_NOTICE} to stderr, at most once per process. + * Emit the collection disclosure to stderr, at most once per process. * * Called immediately before the first network request, so a run that is opted * out, skipped, or served from cache never makes a request AND never emits the * notice. stderr only — stdout is the JSON-RPC channel. + * + * @param registry - The registry actually about to be contacted, so a + * `SPE_NPM_REGISTRY` override is disclosed truthfully rather than the default + * public host. Already validated by {@link resolveRegistry}. */ -function emitCollectionNotice(): void { +function emitCollectionNotice(registry: string): void { if (collectionNoticeEmitted) return; collectionNoticeEmitted = true; - logger.log(COLLECTION_NOTICE); + logger.log(collectionNotice(registry)); } /** @@ -484,7 +508,10 @@ function emitCollectionNotice(): void { * whole request is skipped — so there is no "request without a User-Agent" * case for this endpoint.) */ -async function fetchDistTags(url: string): Promise | null> { +async function fetchDistTags( + url: string, + registry: string, +): Promise | null> { let expectedHost: string; try { expectedHost = new URL(url).host; @@ -493,7 +520,7 @@ async function fetchDistTags(url: string): Promise | null } // Last thing before any egress: tell the user what is about to happen. - emitCollectionNotice(); + emitCollectionNotice(registry); try { const response = await fetch(url, { @@ -652,16 +679,26 @@ function cachedTargetVersion(cache: UpdateCache): string | undefined { return cache.channelVersion ?? cache.latest; } -/** Render the single notice appended to a tool result. */ +/** + * Render the single notice appended to a tool result. + * + * The remediation wording is deliberately execution-mode neutral. This server is + * commonly launched by an MCP client through an unpinned `npx -y @microsoft/spe-mcp`, + * in which case `npm install -g` would update something the client never runs. + * We therefore name the package *spec* to move to and let the reader apply it to + * whichever launch mechanism they actually configured. + */ function renderNotice(update: UpdateAvailable): string { const lines = [ `Update available: ${update.package} ${update.current} -> ${update.latest}` + - `${update.channel ? ` (${update.channel} channel)` : ""}. Update with: ${update.command}`, + `${update.channel ? ` (${update.channel} channel)` : ""}.`, + `To update, point your MCP client at ${update.packageSpec} — update or pin the ` + + `package spec in the client config (for example the npx args), or reinstall ` + + `the copy you actually launch (for example npm install -g ${update.packageSpec} ` + + `for a global install). An unpinned npx launch may keep starting a cached build.`, ]; - if (update.stable) { - lines.push( - `Latest stable release: ${update.stable} (npm install -g ${update.package}@latest).`, - ); + if (update.stable && update.stablePackageSpec) { + lines.push(`Latest stable release: ${update.stable} (spec ${update.stablePackageSpec}).`); } lines.push( "Nothing was downloaded or installed; this is a notification only. " + @@ -754,7 +791,7 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { } else { notifiedFor = cached ? [...cached.notifiedFor] : []; checkedAt = now; - tags = await fetchDistTags(url); + tags = await fetchDistTags(url, registry); if (tags === null) { writeCache({ version: 1, @@ -814,8 +851,10 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { latest, channel, // Only call out stable separately when it is a different, additional target. - ...(stableVersion && stableVersion !== latest ? { stable: stableVersion } : {}), - command: `npm install -g ${PACKAGE_NAME}@${channelVersion && channel ? channel : "latest"}`, + ...(stableVersion && stableVersion !== latest + ? { stable: stableVersion, stablePackageSpec: `${PACKAGE_NAME}@latest` } + : {}), + packageSpec: `${PACKAGE_NAME}@${channelVersion && channel ? channel : "latest"}`, }; status = { From dad1fe7fb5ffa9992d73bffb81ec2af1624c597c Mon Sep 17 00:00:00 2001 From: grjoseph Date: Thu, 20 Aug 2026 21:39:30 -0700 Subject: [PATCH 5/9] docs(update-check): disclose npm registry boundary and frame notice as informational Documentation and wording follow-ups for the npm update-awareness check. - README: the "Data collection" notice now states plainly that, separately from anything sent to Microsoft, the default-on update check contacts the public npm registry (registry.npmjs.org, operated by npm, Inc./GitHub), which is not a Microsoft 365 or Azure Online Service and sits outside the Microsoft 365 / Azure compliance boundary, the Product Terms, the DPA, and the EU Data Boundary. The verbatim sample notice was regenerated from the implementation so it cannot drift. - NOTICE.md: added a canonical "Third-party services contacted" section covering purpose, what is sent (unauthenticated, no user identifier, no credentials, tenant, machine, session, or customer data), what the endpoint can observe (source IP, package path, User-Agent, TLS/HTTP connection metadata), the compliance boundary, local cache retention and deletion, the opt-out controls, and the fact that nothing is ever downloaded, installed, or self-updated. NOTICE.md already ships in the published package; a packaging test now asserts both that it is packed and that the disclosure content is present. - PRIVACY.md: cross-references the new NOTICE section and no longer describes applying an update as a manual npm install the reader runs. - Update notice and status_get: the remediation text is now explicitly informational. It states that nothing is installed or changed automatically and that updating requires a person to change the MCP client configuration or reinstall the copy the client actually launches. It is no longer phrased as a command to execute. Auto-update remains out of scope; the check only notifies. No new runtime dependencies (6 unchanged). External privacy, CELA, and OSPO confirmations are tracked outside this repository and are not asserted here. AB#3219463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 12 +++++++++++- NOTICE.md | 39 ++++++++++++++++++++++++++++++++++++++- PRIVACY.md | 11 +++++++++-- README.md | 13 ++++++++++--- src/packaging.test.ts | 25 +++++++++++++++++++++++++ src/tools/status.ts | 2 +- src/update-check.test.ts | 27 ++++++++++++++++++++++++--- src/update-check.ts | 33 ++++++++++++++++++++++----------- src/version.ts | 4 ++-- 9 files changed, 142 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c238de..17e2503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). as "already notified" only when the notice is actually delivered on a tool result, so a process that exits before any tool call replays the notice on the next run instead of losing it. -- **Boundary disclosure.** `README.md`, `PRIVACY.md`, `docs/DATA-FLOW.md`, +- **Boundary disclosure.** `NOTICE.md` (new **Third-party services contacted** section), + `README.md`, `PRIVACY.md`, `docs/DATA-FLOW.md`, `docs/SECURITY-CONTROLS.md`, and `docs/TROUBLESHOOTING.md` document that `registry.npmjs.org` (npm, Inc./GitHub) is **not a Microsoft 365 or Azure Online Service** and is therefore the only endpoint **outside the Microsoft 365 / Azure compliance boundary** and @@ -42,6 +43,15 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). metadata, and the request time; that no auto-update exists; and that Node's built-in `fetch` cannot route through `HTTP(S)_PROXY` — an open, unresolved tradeoff accepted to preserve the zero-runtime-dependency budget. +- **Informational-only update guidance.** The update notice and `status_get` state that the + message is informational, that nothing is installed or changed automatically, and that + updating requires a person to change the MCP client configuration (or reinstall the copy the + client actually launches) — it is never phrased as a command to run. The guidance is + execution-mode neutral (`npx`, global install, or project-local install) and reports the + package spec to target rather than a single install command. The published package now also + ships `NOTICE.md`, `PRIVACY.md`, `CHANGELOG.md`, `SUPPORT.md`, `SECURITY.md`, + `docs/DATA-FLOW.md`, `docs/SECURITY-CONTROLS.md`, and `docs/TROUBLESHOOTING.md`, so the + disclosure links in the installed `README.md` resolve. - **Per-instance data directory.** New `--data-dir ` flag and `SPE_DATA_DIR` environment variable select where the provisioning `state.json` and MSAL token cache are stored (precedence: flag > env > default `~/.spe-mcp`). Point each diff --git a/NOTICE.md b/NOTICE.md index f491e89..ba649dc 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -23,7 +23,10 @@ consent to these practices. > (`spe-mcp-server/`) attached to the Microsoft Graph and Azure Resource Manager > requests you already make on your own behalf; it carries no personal, tenant, or usage > data and is used only for aggregate traffic attribution. It is **on by default** and can be -> suppressed with `SPE_MCP_COLLECT_TELEMETRY=false` (see below). See [PRIVACY.md](PRIVACY.md) and +> suppressed with `SPE_MCP_COLLECT_TELEMETRY=false` (see below). Separately from anything sent +> to Microsoft, a default-on update check contacts the public npm registry — see +> [Third-party services contacted](#third-party-services-contacted) below. See +> [PRIVACY.md](PRIVACY.md) and > [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full data-flow description. ## Telemetry configuration @@ -35,6 +38,40 @@ attribution — there is no usage-analytics channel and no personal, tenant, or opt out, set `SPE_MCP_COLLECT_TELEMETRY=false` in your environment; the product token is then omitted from all outbound requests. +## Third-party services contacted + +Beyond the Microsoft services you explicitly configure (Microsoft Graph, Azure Resource Manager, +and Microsoft Entra ID), this build contacts **one non-Microsoft service by default**. + +**Public npm registry — `https://registry.npmjs.org` (npm, Inc., a GitHub company).** + +- **Purpose.** After the server connects, it makes a single fire-and-forget request to read the + published version list (`dist-tags`) for `@microsoft/spe-mcp`, so it can tell you in a tool + result when a newer release exists. +- **What is sent.** The request is **unauthenticated and carries no user identifier**. No + credentials, tokens, cookies, account, tenant, machine, session, install, or customer data are + sent. The only application-supplied values are the package name in the request path and a + static product `User-Agent` (`spe-mcp-server/`), which is omitted entirely when + telemetry is disabled — and in that case no request is made at all. +- **What the endpoint can observe.** As with any HTTPS request, the operator can see your + **source IP address**, the requested **package path**, the static **`User-Agent`**, and + standard **TLS/HTTP connection metadata** (TLS handshake details, timestamps, request size). +- **Compliance boundary.** npm and GitHub are **not Microsoft 365 or Azure Online Services**. + This endpoint sits **outside the Microsoft 365 / Azure compliance boundary** and is **not** + covered by the Microsoft Product Terms, the Microsoft Products and Services Data Protection + Addendum (DPA), or the EU Data Boundary. Data handling is governed by the applicable + **GitHub/npm privacy statements**, not by your Microsoft agreements. +- **Nothing is downloaded or installed.** The check reads version metadata only. This build + never downloads, installs, executes, or self-updates anything. Acting on a notice is a human + decision. +- **Local retention.** The result is cached in a local file under the server data directory + until you delete it (`spe-mcp logout` and `spe-mcp auth --reset` remove it). +- **How to turn it off (no request is made).** `--no-update-check`, + `SPE_MCP_UPDATE_CHECK=false` (preferred), `SPE_NO_UPDATE_CHECK` (legacy alias), + `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false`, or any recognized CI environment. + +See [PRIVACY.md](PRIVACY.md) and [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full disclosure. + ## Compliance responsibility This MCP server may interact with clients and services outside Microsoft compliance diff --git a/PRIVACY.md b/PRIVACY.md index 705bbe1..77cce22 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -82,7 +82,9 @@ newer releases, which can also be turned off. Specifically: bounced to a different host. **No auto-update.** Nothing is downloaded, installed, executed, or modified. The tool only - *notifies* you; applying an update is always a manual `npm install` you run yourself. + *notifies* you; the notice is informational, and acting on it is a human decision — updating + means pointing your MCP client configuration (or reinstalling the copy it actually launches) + at a newer package spec. **Local retention.** The result is cached on your machine at `/update-check.json`, written with the same owner-only permissions as the token @@ -120,7 +122,12 @@ travels to each. > completeness; **this build opens no usage-analytics channel** — the only Microsoft-bound > signal is the product `User-Agent` attribution token described above, which is on by default > and can be turned off (see [Turning it off](#turning-it-off) and the -> [Telemetry configuration](NOTICE.md#telemetry-configuration) note). +> [Telemetry configuration](NOTICE.md#telemetry-configuration) note). Separately from anything +> sent to Microsoft, the default-on update check contacts the public npm registry, which is +> **not a Microsoft 365 or Azure Online Service** and is outside the M365/Azure compliance +> boundary, the Product Terms/DPA, and the EU Data Boundary — see +> [Update check (public npm registry)](#what-the-tool-collects-and-sends) above and +> [NOTICE.md — Third-party services contacted](NOTICE.md#third-party-services-contacted). ## Service-side data handling diff --git a/README.md b/README.md index 09842c8..feedc20 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,8 @@ release and — if one exists — appends a short notice to a single tool result ```text Update available: @microsoft/spe-mcp 0.2.0-alpha.1 -> 0.2.0-alpha.4 (alpha channel). -To update, point your MCP client at @microsoft/spe-mcp@alpha — update or pin the package spec in the client config (for example the npx args), or reinstall the copy you actually launch (for example npm install -g @microsoft/spe-mcp@alpha for a global install). An unpinned npx launch may keep starting a cached build. -Nothing was downloaded or installed; this is a notification only. Disable this check with --no-update-check or SPE_MCP_UPDATE_CHECK=false. +This notice is informational only — nothing is installed or changed automatically, and no command should be run in response to it. Updating requires a person to change the MCP client configuration or the installed package: point the client at @microsoft/spe-mcp@alpha by updating or pinning the package spec in the client config (for example the npx args), or have the copy that is actually launched (a global or project-local installation, for instance) reinstalled at that same spec. An unpinned npx launch may keep starting a cached build. +Nothing was downloaded, installed, or executed; this is a notification only, not an instruction to run any command. Disable this check with --no-update-check or SPE_MCP_UPDATE_CHECK=false. ``` The current version and the update state are also reported by `status_get`, so @@ -718,7 +718,14 @@ to provide and improve products and services, and your use of the software opera consent to these practices (full text in [NOTICE.md](NOTICE.md#data-collection)). **This build opens no usage-analytics channel** — the only Microsoft-bound signal is the product `User-Agent` attribution token described above, which you can turn off with -`SPE_MCP_COLLECT_TELEMETRY=false`. +`SPE_MCP_COLLECT_TELEMETRY=false`. Separately from anything sent to Microsoft, the default-on +[update check](#update-notifications) contacts the **public npm registry** +(`registry.npmjs.org`, operated by npm, Inc./GitHub). That endpoint is **not a Microsoft 365 or +Azure Online Service**: it sits **outside the Microsoft 365 / Azure compliance boundary** and is +not covered by the Microsoft Product Terms, the Microsoft Products and Services Data Protection +Addendum (DPA), or the EU Data Boundary. See the npm registry disclosure above and +[NOTICE.md — Third-party services contacted](NOTICE.md#third-party-services-contacted) for what +is and is not disclosed, and how to turn it off. **Telemetry configuration.** Attribution is gated by the `SPE_MCP_COLLECT_TELEMETRY` environment variable and is **on by default**. The only telemetry emitted is the static product diff --git a/src/packaging.test.ts b/src/packaging.test.ts index c01108a..bb1feb3 100644 --- a/src/packaging.test.ts +++ b/src/packaging.test.ts @@ -146,6 +146,31 @@ describe("packaging: disclosure documents are published", () => { // absence is what makes the allow-list above authoritative. expect(existsSync(join(pkgRoot, ".npmignore"))).toBe(false); }); + + /** + * CELA B2: NOTICE.md is the canonical legal notice file and must both ship in + * the tarball and carry the third-party-services disclosure for the default-on + * update check. README and PRIVACY.md deep-link to that anchor, so silently + * dropping the section would leave dangling links in an installed copy. + */ + it("packs NOTICE.md with the third-party services disclosure", () => { + expect((pkg.files ?? []) as string[]).toContain("NOTICE.md"); + + const notice = readFileSync(join(pkgRoot, "NOTICE.md"), "utf8"); + // The anchor README.md and PRIVACY.md link to. + expect(notice).toMatch(/^##\s+Third-party services contacted\s*$/m); + // Endpoint and operator. + expect(notice).toContain("registry.npmjs.org"); + expect(notice).toMatch(/npm, Inc/i); + // Boundary language required by CELA/Privacy review. + expect(notice).toMatch(/not\s+(a\s+)?Microsoft 365 or Azure Online Service/i); + expect(notice).toMatch(/EU Data Boundary/i); + expect(notice).toMatch(/Product Terms/i); + // No identifiers, no auto-update, and an opt-out must all be stated. + expect(notice).toMatch(/unauthenticated and carries no user identifier/i); + expect(notice).toMatch(/never downloads, installs, executes, or self-updates/i); + expect(notice).toContain("SPE_MCP_UPDATE_CHECK=false"); + }); }); describe("packaging: complete metadata", () => { diff --git a/src/tools/status.ts b/src/tools/status.ts index 9da9926..b84fa11 100644 --- a/src/tools/status.ts +++ b/src/tools/status.ts @@ -40,7 +40,7 @@ function versionRows(): string { updateCell = "✅ up to date"; break; case "update-available": - updateCell = `⬆️ ${status.latestVersion ?? "newer version"} available — update the package spec your MCP client launches to \`${status.updateAvailable?.packageSpec ?? ""}\``; + updateCell = `⬆️ ${status.latestVersion ?? "newer version"} available — informational only; updating requires a person to point the MCP client config (or the installed copy) at \`${status.updateAvailable?.packageSpec ?? ""}\``; break; default: updateCell = "— unavailable (registry not reachable)"; diff --git a/src/update-check.test.ts b/src/update-check.test.ts index 2c44717..8f75469 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -490,10 +490,31 @@ describe("renderNotice", () => { // ...warns about the unpinned npx caching trap... expect(text).toMatch(/npx/i); expect(text).toMatch(/cached build/i); - // ...and offers the global install only as one example among modes. - expect(text).toContain("for example npm install -g"); + // ...and names the installation modes without dictating a shell command. + expect(text).toMatch(/global or project-local installation/i); + expect(text).toMatch(/reinstalled/i); // Still explicitly notify-only. - expect(text).toContain("Nothing was downloaded or installed"); + expect(text).toContain("Nothing was downloaded, installed, or executed"); + }); + + // CELA R2: the notice rides a tool result an agent may act on, so it must read + // as information, never as a command an autonomous client should execute. + it("frames remediation as informational and human-driven, not an executable command", () => { + const text = __testing.renderNotice({ + package: PACKAGE_NAME, + current: "1.0.0-alpha.1", + latest: "1.0.0-alpha.2", + channel: "alpha", + packageSpec: `${PACKAGE_NAME}@alpha`, + }); + expect(text).toMatch(/informational only/i); + expect(text).toMatch(/no command should be run in response/i); + expect(text).toMatch(/not an instruction to run any command/i); + // Updating is a person changing config, not the server acting. + expect(text).toMatch(/requires a person/i); + // No copy-pasteable install command anywhere in the notice. + expect(text).not.toMatch(/npm install/i); + expect(text).not.toMatch(/npm i\b/i); }); it("calls out a separate stable target when one exists", () => { diff --git a/src/update-check.ts b/src/update-check.ts index ee6a35b..5ef72c0 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -682,27 +682,38 @@ function cachedTargetVersion(cache: UpdateCache): string | undefined { /** * Render the single notice appended to a tool result. * - * The remediation wording is deliberately execution-mode neutral. This server is - * commonly launched by an MCP client through an unpinned `npx -y @microsoft/spe-mcp`, - * in which case `npm install -g` would update something the client never runs. - * We therefore name the package *spec* to move to and let the reader apply it to - * whichever launch mechanism they actually configured. + * Two constraints shape this wording: + * + * 1. It is **informational**, never an instruction to run a command. The notice is + * read by agents as well as humans, so it must not look like a shell command to + * execute. Updating is a human decision that changes MCP client configuration or + * a package installation; this server never performs it. + * 2. It is **execution-mode neutral**. This server is commonly launched by an MCP + * client through an unpinned `npx -y @microsoft/spe-mcp`, in which case updating a + * global installation would update something the client never runs. We therefore + * name the package *spec* to move to and let a person apply it to whichever launch + * mechanism they actually configured. */ function renderNotice(update: UpdateAvailable): string { const lines = [ `Update available: ${update.package} ${update.current} -> ${update.latest}` + `${update.channel ? ` (${update.channel} channel)` : ""}.`, - `To update, point your MCP client at ${update.packageSpec} — update or pin the ` + - `package spec in the client config (for example the npx args), or reinstall ` + - `the copy you actually launch (for example npm install -g ${update.packageSpec} ` + - `for a global install). An unpinned npx launch may keep starting a cached build.`, + `This notice is informational only — nothing is installed or changed ` + + `automatically, and no command should be run in response to it. Updating ` + + `requires a person to change the MCP client configuration or the installed ` + + `package: point the client at ${update.packageSpec} by updating or pinning the ` + + `package spec in the client config (for example the npx args), or have the copy ` + + `that is actually launched (a global or project-local installation, for ` + + `instance) reinstalled at that same spec. An unpinned npx launch may keep ` + + `starting a cached build.`, ]; if (update.stable && update.stablePackageSpec) { lines.push(`Latest stable release: ${update.stable} (spec ${update.stablePackageSpec}).`); } lines.push( - "Nothing was downloaded or installed; this is a notification only. " + - "Disable this check with --no-update-check or SPE_MCP_UPDATE_CHECK=false.", + "Nothing was downloaded, installed, or executed; this is a notification only, " + + "not an instruction to run any command. Disable this check with " + + "--no-update-check or SPE_MCP_UPDATE_CHECK=false.", ); return lines.join("\n"); } diff --git a/src/version.ts b/src/version.ts index 081b0fa..e58cabc 100644 --- a/src/version.ts +++ b/src/version.ts @@ -33,7 +33,7 @@ export const PACKAGE_VERSION: string = packageJson.version; * The published npm package name, sourced from `package.json`. * * Consumed by the update-awareness check (update-check.ts) so the registry it - * queries and the `npm install` hint it prints always name the package this - * build was actually cut from, even if the package is ever renamed. + * queries and the package spec it reports always name the package this build was + * actually cut from, even if the package is ever renamed. */ export const PACKAGE_NAME: string = packageJson.name; From b7f8e572000bdadc9c91e8d4a01378abceff1257 Mon Sep 17 00:00:00 2001 From: grjoseph Date: Fri, 21 Aug 2026 01:02:44 -0700 Subject: [PATCH 6/9] docs(data-flow): replace absolute-anonymity claims with precise transport metadata The npm update-check disclosure in docs/DATA-FLOW.md claimed the registry lookup "carries no data of yours" and "transmits nothing about you". Both conflict with the IP address and standard TLS/HTTP connection metadata that the same document already discloses in the outbound endpoint table. Replace both statements with precise wording: the request sends no customer content and no application-level user, tenant, subscription, or install identifier, while the HTTPS connection itself necessarily exposes the source IP address and standard transport metadata (TLS handshake and SNI, Host / Accept / User-Agent headers, request timing) to the registry operator. The vocabulary matches the endpoint table and the NOTICE.md third-party services section so the disclosure reads consistently across the package. Also publish CONTRIBUTING.md so the relative link from the shipped README resolves in an installed copy, and add a packaging test that every root document the README links to relatively is present in the files allow-list. AB#3219463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 +++++++--- docs/DATA-FLOW.md | 16 +++++++++++----- package.json | 1 + src/packaging.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e2503..5468c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). connection discloses IP address, the static `User-Agent`, standard TLS/HTTP connection metadata, and the request time; that no auto-update exists; and that Node's built-in `fetch` cannot route through `HTTP(S)_PROXY` — an open, unresolved - tradeoff accepted to preserve the zero-runtime-dependency budget. + tradeoff accepted to preserve the zero-runtime-dependency budget. `docs/DATA-FLOW.md` + states precisely that the registry lookup sends no customer content and no + application-level user, tenant, subscription, or install identifier, while the HTTPS + connection itself still exposes the source IP address and standard transport metadata — + it makes no absolute-anonymity claim. - **Informational-only update guidance.** The update notice and `status_get` state that the message is informational, that nothing is installed or changed automatically, and that updating requires a person to change the MCP client configuration (or reinstall the copy the @@ -50,8 +54,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). execution-mode neutral (`npx`, global install, or project-local install) and reports the package spec to target rather than a single install command. The published package now also ships `NOTICE.md`, `PRIVACY.md`, `CHANGELOG.md`, `SUPPORT.md`, `SECURITY.md`, - `docs/DATA-FLOW.md`, `docs/SECURITY-CONTROLS.md`, and `docs/TROUBLESHOOTING.md`, so the - disclosure links in the installed `README.md` resolve. + `CONTRIBUTING.md`, `docs/DATA-FLOW.md`, `docs/SECURITY-CONTROLS.md`, and + `docs/TROUBLESHOOTING.md`, so the disclosure links in the installed `README.md` resolve. - **Per-instance data directory.** New `--data-dir ` flag and `SPE_DATA_DIR` environment variable select where the provisioning `state.json` and MSAL token cache are stored (precedence: flag > env > default `~/.spe-mcp`). Point each diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 0a2055d..8756ac6 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -16,8 +16,12 @@ MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft - Every outbound network call is HTTPS. All calls that carry your data go to a **Microsoft-operated** endpoint, made **on your behalf**, using **your** credentials, into **your** tenant and subscription. The single exception is an unauthenticated public-package - lookup on the npm registry (below), sent without a user identifier, which carries no data of - yours. + lookup on the npm registry (below). That request sends **no customer content and no + application-level user, tenant, subscription, or install identifier** — the package name in + the request path is the only application-level content. As with any HTTPS request, the + connection itself necessarily exposes your **source IP address** and **standard transport + metadata** (TLS handshake and SNI, `Host`/`Accept`/`User-Agent` headers, request timing) to + the registry operator; see the endpoint table below for the full disclosure. ## Outbound endpoints @@ -96,6 +100,8 @@ stamped on outbound Graph/ARM requests. It is **on by default**; set new signal — outbound calls simply fall back to the underlying tool's default `User-Agent` (the Azure CLI's own token for `az`/`azd`; the Node runtime default for direct Graph calls), whose logging is governed by those services' own terms. The npm update check is **not** -telemetry: it is an inbound-information request (does a newer version exist?) that transmits -nothing about you and can be disabled independently. See [PRIVACY.md](../PRIVACY.md) for -details. +telemetry: it is an inbound-information request (does a newer version exist?) that sends **no +customer content and no application-level user, tenant, subscription, or install identifier**, +and can be disabled independently. Like any HTTPS request it still exposes your **source IP +address** and **standard transport metadata** to the registry operator, as documented in the +endpoint table above. See [PRIVACY.md](../PRIVACY.md) for details. diff --git a/package.json b/package.json index 35ea403..59abceb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "docs/SECURITY-CONTROLS.md", "docs/TROUBLESHOOTING.md", "CHANGELOG.md", + "CONTRIBUTING.md", "LICENSE", "NOTICE.md", "PRIVACY.md", diff --git a/src/packaging.test.ts b/src/packaging.test.ts index bb1feb3..d383bc6 100644 --- a/src/packaging.test.ts +++ b/src/packaging.test.ts @@ -118,6 +118,7 @@ describe("packaging: THIRD-PARTY-NOTICES", () => { describe("packaging: disclosure documents are published", () => { const DISCLOSURE_DOCS = [ "CHANGELOG.md", + "CONTRIBUTING.md", "NOTICE.md", "PRIVACY.md", "README.md", @@ -147,6 +148,28 @@ describe("packaging: disclosure documents are published", () => { expect(existsSync(join(pkgRoot, ".npmignore"))).toBe(false); }); + /** + * The README ships in the tarball and links to CONTRIBUTING.md with a *relative* + * link, so the doc has to be published for that link to resolve in an installed + * copy. This test pins the link target and the allow-list entry together so a + * rename of either side fails loudly instead of silently breaking the link. + */ + it("publishes every root document the README links to relatively", () => { + const readme = readFileSync(join(pkgRoot, "README.md"), "utf8"); + const files = (pkg.files ?? []) as string[]; + + const linked = new Set(); + for (const match of readme.matchAll(/\]\(\.?\/?([A-Z][A-Z0-9._-]*\.md)\)/g)) { + linked.add(match[1]!); + } + + expect(linked, "README should link to CONTRIBUTING.md").toContain("CONTRIBUTING.md"); + for (const doc of linked) { + expect(files, `README links to ${doc}; it must be published`).toContain(doc); + expect(existsSync(join(pkgRoot, doc)), `${doc} is missing from the repo`).toBe(true); + } + }); + /** * CELA B2: NOTICE.md is the canonical legal notice file and must both ship in * the tarball and carry the third-party-services disclosure for the default-on From fda812f9a0106b1d9127c5c21509cc784e40f58e Mon Sep 17 00:00:00 2001 From: grjoseph Date: Mon, 24 Aug 2026 10:42:41 -0700 Subject: [PATCH 7/9] fix(update-check): reject unusable registry responses (AB#3219463) Cache malformed or tagless packuments as unavailable, while preserving mixed valid tags. Describe configured registries neutrally in runtime status, collection notices, tests, and disclosure docs. Copilot-Session-Id: 9b07fed7-d2cf-4209-8682-6c3004401c04 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- NOTICE.md | 4 ++++ PRIVACY.md | 43 +++++++++++++++++++++-------------- README.md | 30 ++++++++++++++++--------- docs/DATA-FLOW.md | 23 +++++++++++-------- docs/TROUBLESHOOTING.md | 12 +++++----- src/tools/status.test.ts | 38 +++++++++++++++++++++++++++++++ src/tools/status.ts | 8 +++++-- src/update-check.test.ts | 48 ++++++++++++++++++++++++++++++++++------ src/update-check.ts | 41 +++++++++++++++++++++++----------- 9 files changed, 184 insertions(+), 63 deletions(-) diff --git a/NOTICE.md b/NOTICE.md index ba649dc..163dc33 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -61,6 +61,10 @@ and Microsoft Entra ID), this build contacts **one non-Microsoft service by defa covered by the Microsoft Product Terms, the Microsoft Products and Services Data Protection Addendum (DPA), or the EU Data Boundary. Data handling is governed by the applicable **GitHub/npm privacy statements**, not by your Microsoft agreements. +- **Configured registry.** If you set `SPE_NPM_REGISTRY`, the update check contacts that endpoint + instead of the public npm registry. Its operator, terms, data handling, and compliance boundary + depend on your configuration; the server's notice identifies it neutrally rather than + attributing it to npm/GitHub or assigning it to a boundary. - **Nothing is downloaded or installed.** The check reads version metadata only. This build never downloads, installs, executes, or self-updates anything. Acting on a notice is a human decision. diff --git a/PRIVACY.md b/PRIVACY.md index 77cce22..0867241 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -35,12 +35,10 @@ newer releases, which can also be turned off. Specifically: aggregate traffic driven by this tool. It is a request header on calls you already make — not a separate data feed — and it is **on by default**; set `SPE_MCP_COLLECT_TELEMETRY=false` to omit it (see [Turning it off](#turning-it-off)). -- **Update check (public npm registry — the only destination that is not a Microsoft 365 or - Azure Online Service, and the only destination outside the compliance boundary).** At most - once every 24 hours the tool reads - the published version list for `@microsoft/spe-mcp` from the public npm registry - (`https://registry.npmjs.org`, override with `SPE_NPM_REGISTRY`) so it can tell you when a - newer release exists (`src/update-check.ts`). +- **Update check (public npm registry by default; configurable).** At most once every 24 hours + the tool reads the published version list for `@microsoft/spe-mcp` from + `https://registry.npmjs.org`, or from the HTTPS registry you set with `SPE_NPM_REGISTRY`, so + it can tell you when a newer release exists (`src/update-check.ts`). > **Boundary disclosure.** `registry.npmjs.org` is operated by **npm, Inc. (GitHub)**. It is > **not a Microsoft 365 or Azure Online Service**, so it is **outside the Microsoft 365 / @@ -49,6 +47,11 @@ newer releases, which can also be turned off. Specifically: > Microsoft Products and Services Data Protection Addendum (DPA)**; it is governed by the > [npm privacy policy](https://docs.npmjs.com/policies/privacy). + > **Configured-registry disclosure.** If you set `SPE_NPM_REGISTRY`, the request goes to that + > endpoint instead of `registry.npmjs.org`. Its operator, contractual terms, data handling, + > and compliance boundary depend on your configuration; this project does not classify a + > configured endpoint as npm/GitHub or as inside or outside any particular boundary. + **Exactly one request is made,** to the exact package path with no query string and no fragment: @@ -56,10 +59,11 @@ newer releases, which can also be turned off. Specifically: GET https://registry.npmjs.org/@microsoft%2fspe-mcp ``` - **What the third party can see.** The request is an **unauthenticated HTTP GET of + **What the registry operator can see.** The request is an **unauthenticated HTTP GET of public package metadata, sent without a user identifier** — the same lookup `npm view` performs. The request body and headers - carry no identifiers, but the connection itself necessarily discloses to npm: + carry no identifiers, but the connection itself necessarily discloses to the registry + operator (npm for the default endpoint): | Disclosed to npm | Why | |------------------|-----| @@ -98,8 +102,10 @@ newer releases, which can also be turned off. Specifically: delete it, or remove the file by hand. **First-run notice.** Before the **first** network request in a process, the tool prints a - one-time notice to **stderr** naming the endpoint, the boundary, and how to turn the check - off. No notice is printed when the check is disabled or served from cache. + one-time notice to **stderr** naming the endpoint and how to turn the check off. The default + endpoint gets the npm/GitHub boundary disclosure; a configured endpoint is described + neutrally because its operator and boundary depend on your configuration. No notice is + printed when the check is disabled or served from cache. **Turning it off.** The check is **skipped automatically** in CI and when running from a source checkout, and can be disabled outright (see [Turning it off](#turning-it-off)); when @@ -123,11 +129,14 @@ travels to each. > signal is the product `User-Agent` attribution token described above, which is on by default > and can be turned off (see [Turning it off](#turning-it-off) and the > [Telemetry configuration](NOTICE.md#telemetry-configuration) note). Separately from anything -> sent to Microsoft, the default-on update check contacts the public npm registry, which is +> sent to Microsoft, the default-on update check contacts the public npm registry by default, +> which is > **not a Microsoft 365 or Azure Online Service** and is outside the M365/Azure compliance > boundary, the Product Terms/DPA, and the EU Data Boundary — see > [Update check (public npm registry)](#what-the-tool-collects-and-sends) above and -> [NOTICE.md — Third-party services contacted](NOTICE.md#third-party-services-contacted). +> [NOTICE.md — Third-party services contacted](NOTICE.md#third-party-services-contacted). A +> configured registry replaces that endpoint and has configuration-dependent ownership and +> boundary treatment as described above. ## Service-side data handling @@ -155,10 +164,12 @@ carry the underlying tool's default `User-Agent` instead (e.g. the Azure CLI's o `az`/`azd`, or the Node runtime default for direct Graph calls), whose logging is governed by those services' own terms. -The **update check** — the only outbound call to a service that is **not a Microsoft 365 or Azure -Online Service**, and therefore the only call that leaves the Microsoft 365 / Azure compliance -boundary (and the Product Terms / DPA / EUDB commitments) — is on by default in published -installs. Any one of the following disables it completely: +The **update check** uses the public npm registry by default — the only default outbound +destination that is not a Microsoft 365 or Azure Online Service and therefore the only default +call outside the Microsoft 365 / Azure compliance boundary (and the Product Terms / DPA / EUDB +commitments). A configured registry replaces that endpoint and has configuration-dependent +ownership and boundary treatment. The check is on by default in published installs. Any one of +the following disables it completely: | Opt-out | Effect | |---------|--------| diff --git a/README.md b/README.md index feedc20..26c9751 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,8 @@ package if you installed it globally or locally). ### Update notifications To make it obvious when you are running an old build, the server checks the -public npm registry **once a day, in the background**, for a newer published +public npm registry by default (or the HTTPS registry you set with +`SPE_NPM_REGISTRY`) **once a day, in the background**, for a newer published release and — if one exists — appends a short notice to a single tool result: ```text @@ -155,8 +156,8 @@ How it behaves: own dist-tag, and a newer **stable** release is mentioned separately. - **Quiet.** The notice is shown once per newer version, not on every call. - **Unauthenticated, without a user identifier.** Exactly one unauthenticated - `GET` of the package's public metadata — - `https://registry.npmjs.org/@microsoft%2fspe-mcp`, no query string, + `GET` of the package metadata — by default, + `https://registry.npmjs.org/@microsoft%2fspe-mcp` — with no query string and redirects rejected. No credentials, cookies, `.npmrc`, or `npm` subprocess are involved, and **no install GUID, machine, user, tenant, subscription, or session identifier** is sent. As with any HTTPS request, npm sees your IP @@ -168,7 +169,9 @@ How it behaves: - **Announced.** Before the first check in a process, a one-time notice is printed to **stderr** naming the endpoint actually contacted (the registry from `SPE_NPM_REGISTRY` if you set one, otherwise `registry.npmjs.org`), the - boundary, and the opt-out. + applicable boundary information, and the opt-out. Configured registries are + described neutrally because their operator and compliance boundary depend on + your configuration. - **Cached locally.** The result is stored owner-only at `/update-check.json` and **kept until you delete it**; `spe-mcp logout` and `spe-mcp auth --reset` remove it, and `status_get` prints @@ -178,8 +181,10 @@ How it behaves: > It is **not a Microsoft 365 or Azure Online Service**, so it is not covered by > the Microsoft Product Terms, the Microsoft Products and Services Data > Protection Addendum (DPA), or the EU Data Boundary. It is the **only** endpoint -> this server contacts that is **outside the Microsoft 365 / Azure compliance -> boundary**. Disable the update check to remove it entirely. +> this server contacts by default that is **outside the Microsoft 365 / Azure +> compliance boundary**. If you configure another registry, its operator, +> policies, and compliance boundary depend on your configuration. Disable the +> update check to remove registry egress entirely. > **Known limitation.** Node's built-in `fetch` does not honour `HTTP_PROXY` / > `HTTPS_PROXY` / `NO_PROXY`, so this request cannot be routed through an egress @@ -686,9 +691,9 @@ details see [PRIVACY.md](PRIVACY.md) and [docs/DATA-FLOW.md](docs/DATA-FLOW.md); handling of data you send to its online services is described in the [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement). -The one destination that is **not a Microsoft 365 or Azure Online Service** is the public npm -registry (`https://registry.npmjs.org`, operated by npm, Inc./GitHub), contacted at most once a -day by the [update check](#update-notifications) to read the published version list for +The one destination contacted by default that is **not a Microsoft 365 or Azure Online Service** +is the public npm registry (`https://registry.npmjs.org`, operated by npm, Inc./GitHub), contacted +at most once a day by the [update check](#update-notifications) to read the published version list for `@microsoft/spe-mcp`. ⚠️ Because it is not a Microsoft Online Service, it is **not covered by the Microsoft Product Terms, the Microsoft Products and Services Data Protection Addendum (DPA), or the EU Data Boundary**, and it is **outside the Microsoft 365 / Azure compliance @@ -711,6 +716,9 @@ checkouts. The cached result lives at `/update-check.json`, is retaine delete it, and is removed by `spe-mcp logout` / `spe-mcp auth --reset`. npm's own handling of registry requests is governed by the [npm privacy policy](https://docs.npmjs.com/policies/privacy). +If `SPE_NPM_REGISTRY` is set, the request goes to that configured endpoint instead. Its operator, +policies, and compliance boundary depend on your configuration; the server does not attribute it +to npm/GitHub or classify it as inside or outside a particular boundary. **Data collection (standard Microsoft notice).** The software may collect information about you and your use of the software and send it to Microsoft; Microsoft may use this information @@ -719,13 +727,13 @@ consent to these practices (full text in [NOTICE.md](NOTICE.md#data-collection)) build opens no usage-analytics channel** — the only Microsoft-bound signal is the product `User-Agent` attribution token described above, which you can turn off with `SPE_MCP_COLLECT_TELEMETRY=false`. Separately from anything sent to Microsoft, the default-on -[update check](#update-notifications) contacts the **public npm registry** +[update check](#update-notifications) contacts the **public npm registry by default** (`registry.npmjs.org`, operated by npm, Inc./GitHub). That endpoint is **not a Microsoft 365 or Azure Online Service**: it sits **outside the Microsoft 365 / Azure compliance boundary** and is not covered by the Microsoft Product Terms, the Microsoft Products and Services Data Protection Addendum (DPA), or the EU Data Boundary. See the npm registry disclosure above and [NOTICE.md — Third-party services contacted](NOTICE.md#third-party-services-contacted) for what -is and is not disclosed, and how to turn it off. +is and is not disclosed, how configured registries are handled, and how to turn it off. **Telemetry configuration.** Attribution is gated by the `SPE_MCP_COLLECT_TELEMETRY` environment variable and is **on by default**. The only telemetry emitted is the static product diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 8756ac6..0713d44 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -31,22 +31,25 @@ MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft | Microsoft Graph (`graph.microsoft.com`) | Create/manage app registrations, container types, containers, and content | Your delegated token | The requests you invoke, in your tenant | Microsoft first-party, in-tenant | | Azure Resource Manager (`management.azure.com`) | Register the `Microsoft.Syntex` provider and wire SPE billing to your subscription | Your Azure token | ARM requests in your subscription | Microsoft first-party, in-subscription | | Microsoft Learn MCP (`learn.microsoft.com/api/mcp`) | Read-only public documentation lookup (`docs_search`) | **None** | Documentation queries only — **no customer data** | Microsoft first-party, public docs | -| npm registry (`registry.npmjs.org`, override `SPE_NPM_REGISTRY`) | Update check: read the published version list for `@microsoft/spe-mcp` at most once per 24 h (control **SEC-008**) | **None** | Package name in the request path only. Necessarily discloses your **IP address**, the static **`User-Agent`** `spe-mcp-server/`, **standard TLS/HTTP connection metadata** (TLS handshake and SNI, `Host`/`Accept` headers, request timing), and the request time. Opting out of telemetry suppresses this request entirely. **No** customer data, tenant/user/subscription identifier, install GUID, machine name, session ID, or usage data | ⚠️ **Third party (npm, Inc. / GitHub) — not a Microsoft 365 or Azure Online Service; OUTSIDE the Microsoft 365 / Azure compliance boundary and not covered by the Microsoft Product Terms, DPA, or EUDB** | +| Public npm registry (`registry.npmjs.org`, default) | Update check: read the published version list for `@microsoft/spe-mcp` at most once per 24 h (control **SEC-008**) | **None** | Package name in the request path only. Necessarily discloses your **IP address**, the static **`User-Agent`** `spe-mcp-server/`, **standard TLS/HTTP connection metadata** (TLS handshake and SNI, `Host`/`Accept` headers, request timing), and the request time. Opting out of telemetry suppresses this request entirely. **No** customer data, tenant/user/subscription identifier, install GUID, machine name, session ID, or usage data | ⚠️ **Third party (npm, Inc. / GitHub) — not a Microsoft 365 or Azure Online Service; OUTSIDE the Microsoft 365 / Azure compliance boundary and not covered by the Microsoft Product Terms, DPA, or EUDB** | +| Configured registry (`SPE_NPM_REGISTRY`, when set) | Same update check, sent to the configured HTTPS endpoint instead of the public npm registry | **None** | Same minimal request shape and observable connection metadata as above | **Configuration-dependent.** The operator, terms, data handling, and compliance boundary are determined by the configured endpoint; the server does not classify it as npm/GitHub or assign it to a boundary | -Only two calls leave your tenant, and neither carries customer data: +By default, two calls use public endpoints outside your tenant, and neither carries customer data: - The **Microsoft Learn documentation lookup** is unauthenticated and out-of-tenant; it is host-validated before use (control **SEC-007**) and can be disabled with `--tools`. -- The **npm update check** is the only destination that is **not a Microsoft 365 or Azure Online - Service** and the only endpoint - **outside the Microsoft 365 / Azure compliance boundary**. It issues exactly one request — +- The default **npm update check** destination is the only destination that is **not a Microsoft + 365 or Azure Online Service** and the only endpoint outside the Microsoft 365 / Azure + compliance boundary. It issues exactly one request — `GET https://registry.npmjs.org/@microsoft%2fspe-mcp`, the exact package path with no query string and no fragment — the same request `npm view` issues, with a 2-second timeout, a 64 KB response cap, HTTPS enforced, **redirects to any other host rejected**, no credentials/cookies/`Authorization`/`.npmrc`, and no `npm` subprocess. It only *notifies*; **nothing is downloaded, installed, or executed — there is no auto-update**. Before the first - such request in a process, a one-time notice naming the endpoint, the boundary, and the - opt-out is printed to **stderr**. It is skipped automatically in CI and source checkouts, and + such request in a process, a one-time notice naming the endpoint, its applicable boundary + information, and the opt-out is printed to **stderr**. A `SPE_NPM_REGISTRY` override uses the + same request controls but is described neutrally because its operator and boundary depend on + the configuration. The check is skipped automatically in CI and source checkouts, and disabled by `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, `SPE_NO_UPDATE_CHECK=1` (legacy alias), `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false` (the telemetry opt-out suppresses the registry request @@ -78,14 +81,16 @@ These never leave your machine: - Microsoft Graph, Azure Resource Manager, and SharePoint Embedded are Microsoft Online Services operating **within the Microsoft 365 / Azure compliance boundary**. Requests you make through this tool stay within that boundary and your tenant's configured data location. -- ⚠️ **One endpoint is outside that boundary:** the npm registry (`registry.npmjs.org`), +- ⚠️ **One default endpoint is outside that boundary:** the npm registry (`registry.npmjs.org`), operated by npm, Inc. (GitHub). It is **not a Microsoft 365 or Azure Online Service**, is **not** covered by the Microsoft Product Terms or the Microsoft Products and Services Data Protection Addendum (DPA), and is **not** subject to any **EU Data Boundary** commitment applying to your tenant. Only the package name is requested; the connection discloses your IP address, the static `User-Agent`, standard TLS/HTTP connection metadata (TLS handshake and SNI, `Host`/`Accept` headers, request timing), and the request time. - Disable the update check to remove this endpoint entirely. + Disable the update check to remove this endpoint entirely. If `SPE_NPM_REGISTRY` is set, the + configured endpoint replaces it; that endpoint's operator and boundary depend on your + configuration. - The tool performs **no independent cross-region processing** and stores **no customer content** of its own. Data location, residency, and **EU Data Boundary** commitments are determined by those underlying services and your tenant configuration — not by this tool. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 52e6231..ffac4a2 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -116,12 +116,14 @@ Common situations: In proxy-only environments, disable the check with `SPE_MCP_UPDATE_CHECK=false`. - **Internal/mirror registry.** Set `SPE_NPM_REGISTRY` to your mirror. It must be an `https:` URL with no embedded credentials, query string, or fragment; anything else is ignored and - the check is disabled for that run. Redirects and cross-host responses are rejected. + the check is disabled for that run. Redirects and cross-host responses are rejected. The + notice and `status_get` identify a configured registry neutrally; its operator and compliance + boundary depend on your configuration. - **A one-time stderr notice appeared at startup.** Before the first registry request, the - server prints a single collection notice to **stderr** naming the endpoint - (`registry.npmjs.org`, npm, Inc./GitHub — **outside the Microsoft 365 / Azure compliance - boundary**) and the opt-out. It is informational; stdout is never written to. Set any opt-out - above to suppress it entirely. + server prints a single collection notice to **stderr** naming the endpoint and the opt-out. + For the default `registry.npmjs.org` endpoint it names npm, Inc./GitHub and the boundary; for + a configured registry it does not guess the operator or boundary. It is informational; stdout + is never written to. Set any opt-out above to suppress it entirely. - **Delete the cached update state.** The cache lives at `/update-check.json` (path shown by `status_get`), contains **no identifier**, and is retained until removed. Delete it with `spe-mcp logout`, `spe-mcp auth --reset`, or by removing the file manually. Deleting it diff --git a/src/tools/status.test.ts b/src/tools/status.test.ts index 67599a1..684e686 100644 --- a/src/tools/status.test.ts +++ b/src/tools/status.test.ts @@ -162,4 +162,42 @@ describe("status_get: server version and update state", () => { // A disabled check must never advertise an update. expect(result.content[0].text).not.toContain("available —"); }); + + it("identifies the default public npm registry boundary", async () => { + const statusSpy = vi.spyOn(updateCheck, "getUpdateStatus").mockReturnValue({ + enabled: true, + state: "up-to-date", + currentVersion: PACKAGE_VERSION, + registry: updateCheck.DEFAULT_REGISTRY, + }); + try { + const result = await statusTool.handler({}); + const text = result.content[0].text; + expect(text).toContain(`\`${updateCheck.DEFAULT_REGISTRY}\``); + expect(text).toContain("public npm registry; third party, outside the M365/Azure boundary"); + } finally { + statusSpy.mockRestore(); + } + }); + + it("describes a configured registry without assigning its operator or boundary", async () => { + const statusSpy = vi.spyOn(updateCheck, "getUpdateStatus").mockReturnValue({ + enabled: true, + state: "up-to-date", + currentVersion: PACKAGE_VERSION, + registry: "https://npm.contoso.example", + }); + try { + const result = await statusTool.handler({}); + const text = result.content[0].text; + expect(text).toContain("`https://npm.contoso.example`"); + expect(text).toContain( + "configured registry; operator and compliance boundary depend on your configuration", + ); + expect(text).not.toContain("third party"); + expect(text).not.toContain("outside the M365/Azure boundary"); + } finally { + statusSpy.mockRestore(); + } + }); }); diff --git a/src/tools/status.ts b/src/tools/status.ts index b84fa11..768bccf 100644 --- a/src/tools/status.ts +++ b/src/tools/status.ts @@ -15,7 +15,7 @@ import { assertAzCli, getSignedInIdentity } from "../bootstrap.js"; import { readState } from "../state.js"; import type { McpTool } from "../types.js"; -import { getUpdateStatus } from "../update-check.js"; +import { DEFAULT_REGISTRY, getUpdateStatus } from "../update-check.js"; /** * Render the server-version and update-awareness (SEC-008) rows shared by every @@ -53,7 +53,11 @@ function versionRows(): string { } rows += `| **Last update check** | ${status.lastCheckedAt ?? "never"} |\n`; if (status.registry) { - rows += `| **Update registry** | \`${status.registry}\` (third party, outside the M365/Azure boundary) |\n`; + const registryDescription = + status.registry === DEFAULT_REGISTRY + ? "public npm registry; third party, outside the M365/Azure boundary" + : "configured registry; operator and compliance boundary depend on your configuration"; + rows += `| **Update registry** | \`${status.registry}\` (${registryDescription}) |\n`; } if (status.cacheFile) { rows += `| **Update cache file** | \`${status.cacheFile}\` |\n`; diff --git a/src/update-check.test.ts b/src/update-check.test.ts index 8f75469..3eef3f2 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -272,8 +272,11 @@ describe("extractDistTags", () => { ["dist-tags as an array", '{"dist-tags":[]}'], ["dist-tags as null", '{"dist-tags":null}'], ["dist-tags as a string", '{"dist-tags":"latest"}'], - ])("returns no tags for %s", (_label, raw) => { - expect(__testing.extractDistTags(raw)).toEqual({}); + ["empty dist-tags", '{"dist-tags":{}}'], + ["prototype-only dist-tags", '{"dist-tags":{"__proto__":"9.9.9"}}'], + ["entirely invalid dist-tags", '{"dist-tags":{"latest":"not-semver","next":42}}'], + ])("rejects %s", (_label, raw) => { + expect(__testing.extractDistTags(raw)).toBeNull(); }); it("drops prototype-pollution keys", () => { @@ -617,6 +620,25 @@ describe("runUpdateCheck", () => { expect(readCacheFile()["outcome"]).toBe("success"); }); + it("accepts mixed valid and invalid tags after filtering", async () => { + respondWith( + JSON.stringify({ + "dist-tags": { + latest: PACKAGE_VERSION, + malformed: "not-a-version", + nested: { version: "9.9.9" }, + __proto__: "9.9.9", + }, + }), + ); + + await __testing.runUpdateCheck({}); + + expect(getUpdateStatus().state).toBe("up-to-date"); + expect(takePendingUpdateNotice()).toBeNull(); + expect(readCacheFile()["outcome"]).toBe("success"); + }); + it("ignores older published versions", async () => { respondWith(packument({ latest: "0.0.1" })); await __testing.runUpdateCheck({}); @@ -706,13 +728,15 @@ describe("runUpdateCheck", () => { it.each([ ["garbage that is not JSON", "404"], ["JSON with no dist-tags", '{"name":"x"}'], + ["empty dist-tags", '{"dist-tags":{}}'], ["dist-tags full of junk", '{"dist-tags":{"latest":"not-a-version","next":{"a":1}}}'], ["prototype pollution attempts", '{"dist-tags":{"__proto__":"999.0.0"}}'], - ])("treats %s as no update rather than an error", async (_label, body) => { + ])("treats %s as an unavailable registry response", async (_label, body) => { respondWith(body); await __testing.runUpdateCheck({}); - expect(getUpdateStatus().state).toBe("up-to-date"); + expect(getUpdateStatus().state).toBe("unavailable"); expect(takePendingUpdateNotice()).toBeNull(); + expect(readCacheFile()["outcome"]).toBe("failure"); }); it.each([ @@ -1069,7 +1093,8 @@ describe("privacy: first-run collection notice", () => { }); it("names the endpoint, the boundary, the retention, and the opt-out", () => { - expect(COLLECTION_NOTICE).toContain("npm registry"); + expect(COLLECTION_NOTICE).toContain("public npm registry"); + expect(COLLECTION_NOTICE).toContain("npm, Inc. / GitHub"); expect(COLLECTION_NOTICE).toContain("OUTSIDE the Microsoft 365 / Azure"); expect(COLLECTION_NOTICE).toContain("IP address"); expect(COLLECTION_NOTICE).toContain("User-Agent"); @@ -1142,15 +1167,24 @@ describe("privacy: collection notice names the configured registry", () => { expect(notice).toContain(DEFAULT_REGISTRY); }); - it("names the override host, not the default, when SPE_NPM_REGISTRY is set", async () => { + it("describes an override neutrally without assigning its operator or boundary", async () => { process.env.SPE_NPM_REGISTRY = "https://npm.contoso.example"; await __testing.runUpdateCheck({}); - const notice = lines.find((l) => l.includes("OUTSIDE the Microsoft 365 / Azure")); + const notice = lines.find((l) => l.includes("https://npm.contoso.example")); expect(notice).toBeDefined(); expect(notice).toContain("https://npm.contoso.example"); + expect(notice).toContain("supplied through SPE_NPM_REGISTRY"); + expect(notice).toContain("operator and compliance boundary depend on your configuration"); + expect(notice).toContain("IP address"); + expect(notice).toContain("cached locally until you delete it"); + expect(notice).toContain("Nothing is downloaded, installed, or updated automatically"); + expect(notice).toContain("--no-update-check"); // Telling the user we contacted npmjs.org when we did not would be a // false disclosure. expect(notice).not.toContain(DEFAULT_REGISTRY); + expect(notice).not.toContain("npm, Inc."); + expect(notice).not.toContain("third-party service"); + expect(notice).not.toContain("OUTSIDE the Microsoft 365 / Azure"); }); it("only ever renders a registry that already passed validation", () => { diff --git a/src/update-check.ts b/src/update-check.ts index 5ef72c0..dd9f159 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -154,23 +154,37 @@ const logger = createLogger("Update"); * Build the one-time stderr disclosure emitted immediately BEFORE the first * network request of a process. * - * It must name the endpoint actually contacted, say plainly that the endpoint - * sits outside the Microsoft 365 / Azure compliance boundary, say what the - * connection discloses, say that nothing is installed, and name the opt-out. + * It must name the endpoint actually contacted. The default public npm + * registry gets its explicit operator/compliance-boundary disclosure; an + * operator-supplied override is described neutrally because this process + * cannot know who operates it or which boundary contains it. Both variants say + * what the connection discloses, say that nothing is installed, and name the + * opt-out. * * @param registry - The resolved registry origin. Always a value that already * passed {@link resolveRegistry} (HTTPS, no credentials, no query/fragment, * length-capped), so it is safe to print verbatim. */ export function collectionNotice(registry: string): string { + const destination = + registry === DEFAULT_REGISTRY + ? [ + "The public npm registry is a third-party service operated by npm, Inc. /", + "GitHub OUTSIDE the Microsoft 365 / Azure compliance boundary.", + ] + : [ + "This endpoint was supplied through SPE_NPM_REGISTRY; its operator and", + "compliance boundary depend on your configuration.", + ]; + return [ - `Update check: contacting the npm registry at ${registry} to see whether a`, + `Update check: contacting the registry at ${registry} to see whether a`, `newer version of ${PACKAGE_NAME} has been published.`, - "The npm registry is a third-party service OUTSIDE the Microsoft 365 / Azure", - "compliance boundary. The request is unauthenticated and carries no user", + ...destination, + "The request is unauthenticated and carries no user", "identifier and no account, tenant, machine, session, or content data — but", "the connection itself discloses your IP address, the package name, and the", - "product User-Agent to that third party. The result is cached locally until", + "product User-Agent to the registry operator. The result is cached locally until", "you delete it. Nothing is downloaded, installed, or updated automatically.", "Turn this off with --no-update-check or SPE_MCP_UPDATE_CHECK=false.", ].join(" "); @@ -450,21 +464,22 @@ function emptyTagMap(): Record { * Everything here treats the input as hostile: the JSON may be any shape, keys * may be prototype pollution attempts, and values may be enormous or not * versions at all. Anything that is not a short tag name mapped to a strictly - * valid SemVer string is dropped silently. + * valid SemVer string is dropped. A packument with no usable tags is rejected + * as unavailable rather than being mistaken for a successful current result. */ -function extractDistTags(raw: string): Record { +function extractDistTags(raw: string): Record | null { const result = emptyTagMap(); let parsed: unknown; try { parsed = JSON.parse(raw); } catch { - return result; + return null; } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return result; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; const tags = (parsed as Record)["dist-tags"]; - if (typeof tags !== "object" || tags === null || Array.isArray(tags)) return result; + if (typeof tags !== "object" || tags === null || Array.isArray(tags)) return null; for (const [name, value] of Object.entries(tags as Record)) { if (!isSafeTagName(name)) continue; @@ -473,7 +488,7 @@ function extractDistTags(raw: string): Record { result[name] = value; } - return result; + return Object.keys(result).length > 0 ? result : null; } /** From 316f6ea912ecc3358674f2b039edd79cdb3d40fc Mon Sep 17 00:00:00 2001 From: grjoseph Date: Mon, 24 Aug 2026 13:16:49 -0700 Subject: [PATCH 8/9] fix(update-check): coordinate targets and refreshes (AB#3219463) Track prerelease-channel and stable targets independently so a delivered channel update cannot suppress a later GA notice. Serialize stale-cache refreshes across processes with atomic lock/cache publication, pre-egress reservations, stale-owner liveness checks, and logout generations that prevent cache resurrection. Copilot-Session-Id: 9b07fed7-d2cf-4209-8682-6c3004401c04 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PRIVACY.md | 37 +- README.md | 19 +- docs/DATA-FLOW.md | 17 +- docs/SECURITY-CONTROLS.md | 2 +- docs/TROUBLESHOOTING.md | 20 +- src/secure-fs.test.ts | 23 +- src/secure-fs.ts | 98 ++++++ src/update-check.test.ts | 326 ++++++++++++++++- src/update-check.ts | 645 +++++++++++++++++++++++++++++----- src/update-notice-e2e.test.ts | 15 +- 10 files changed, 1086 insertions(+), 116 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 0867241..9c8c94e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -36,7 +36,9 @@ newer releases, which can also be turned off. Specifically: not a separate data feed — and it is **on by default**; set `SPE_MCP_COLLECT_TELEMETRY=false` to omit it (see [Turning it off](#turning-it-off)). - **Update check (public npm registry by default; configurable).** At most once every 24 hours - the tool reads the published version list for `@microsoft/spe-mcp` from + for the same running package version and registry, across server processes sharing the same + retained data-directory cache, the tool reads the + published version list for `@microsoft/spe-mcp` from `https://registry.npmjs.org`, or from the HTTPS registry you set with `SPE_NPM_REGISTRY`, so it can tell you when a newer release exists (`src/update-check.ts`). @@ -52,8 +54,8 @@ newer releases, which can also be turned off. Specifically: > and compliance boundary depend on your configuration; this project does not classify a > configured endpoint as npm/GitHub or as inside or outside any particular boundary. - **Exactly one request is made,** to the exact package path with no query string and no - fragment: + **Each refresh attempt makes exactly one request,** to the exact package path + with no query string and no fragment: ```text GET https://registry.npmjs.org/@microsoft%2fspe-mcp @@ -81,9 +83,11 @@ newer releases, which can also be turned off. Specifically: **What is never sent:** no credentials, tokens, cookies, or `Authorization` header; no `.npmrc` and no npm subprocess; **no install GUID, machine identifier, hostname, user name, tenant ID, subscription ID, correlation ID, or session ID**; no usage, prompt, or content - data; no data about which tools you invoked. The tool generates and stores **no identifier of - any kind** for this feature. Redirects are rejected outright, so the request cannot be - bounced to a different host. + data; no data about which tools you invoked. The request and persistent cache contain **no + identifier of any kind**. A transient local lock records the operating-system process ID only + to verify that an abandoned lock owner has exited; it is never transmitted or copied into the + persistent cache. Redirects are rejected outright, so the request cannot be bounced to a + different host. **No auto-update.** Nothing is downloaded, installed, executed, or modified. The tool only *notifies* you; the notice is informational, and acting on it is a human decision — updating @@ -99,7 +103,26 @@ newer releases, which can also be turned off. Specifically: registry URL, a timestamp, and which versions you have already been told about — **no identifier**. It is **retained locally until you delete it**: there is no automatic expiry of the file itself, only of its freshness. Run `spe-mcp logout` or `spe-mcp auth --reset` to - delete it, or remove the file by hand. + delete it, or remove the file by hand. A transient owner-only + `update-check.json.lock` file (plus a recovery lock only while reclaiming an abandoned lock) + coordinates processes that share the data directory. Each contains only the local process ID + and lock-acquisition timestamp, is removed after the operation, and an abandoned lock can be + reclaimed after 30 seconds only after the recorded process is no longer alive. The + refresh-lock owner writes the 24-hour attempt timestamp to the cache + before opening the registry connection, so another process cannot start a duplicate request + if the first process exits after egress. Changing the running package version or registry, or + deleting the cache, intentionally starts a new 24-hour window. If the process exits while + atomically publishing a lock, an owner-only `.tmp-*` lock file can remain; the next eligible + check removes it after 30 seconds once its recorded process has exited. With no later check it + remains local until you delete the data directory. The same atomic-write pattern can leave an + owner-only `update-check.json.tmp-*` file after an abrupt exit; it is cleaned by the next + eligible check after 30 seconds or by logout/reset. + + Logout/reset also writes an owner-only `update-check.json.deleted` generation containing only + a timestamp before deleting the cached result. That local tombstone prevents a registry + request already in flight from recreating the cache after deletion. It is not transmitted, + does not contain the registry result or any identifier, and remains until the data directory + is deleted. **First-run notice.** Before the **first** network request in a process, the tool prints a one-time notice to **stderr** naming the endpoint and how to turn the check off. The default diff --git a/README.md b/README.md index 26c9751..7a249ee 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,9 @@ package if you installed it globally or locally). To make it obvious when you are running an old build, the server checks the public npm registry by default (or the HTTPS registry you set with `SPE_NPM_REGISTRY`) **once a day, in the background**, for a newer published -release and — if one exists — appends a short notice to a single tool result: +release and — if one exists — appends a short notice to a single tool result. +That daily limit applies to processes sharing the data directory for the same +running package version and registry while the cache is retained: ```text Update available: @microsoft/spe-mcp 0.2.0-alpha.1 -> 0.2.0-alpha.4 (alpha channel). @@ -153,8 +155,10 @@ How it behaves: - **Never blocks a tool call.** The check is fire-and-forget with a 2-second timeout; if the registry is slow or unreachable, the result is simply dropped. - **Channel-aware.** A prerelease install (e.g. `alpha`) is compared against its - own dist-tag, and a newer **stable** release is mentioned separately. -- **Quiet.** The notice is shown once per newer version, not on every call. + own dist-tag. The `latest` target is mentioned separately as **stable** only + when it resolves to a non-prerelease version. +- **Quiet.** Channel and stable targets are tracked independently, and each is + shown once per newer version rather than on every call. - **Unauthenticated, without a user identifier.** Exactly one unauthenticated `GET` of the package metadata — by default, `https://registry.npmjs.org/@microsoft%2fspe-mcp` — with no query string and @@ -175,7 +179,11 @@ How it behaves: - **Cached locally.** The result is stored owner-only at `/update-check.json` and **kept until you delete it**; `spe-mcp logout` and `spe-mcp auth --reset` remove it, and `status_get` prints - the path. + the path. Processes sharing the data directory coordinate stale-cache + refreshes with an owner-only lock and write the 24-hour attempt reservation + before egress, preventing concurrent starts or a mid-request process exit from + producing another request inside that window. Changing the running package + version or registry, or deleting the cache, intentionally starts a new window. > ⚠️ **Boundary note.** `registry.npmjs.org` is operated by npm, Inc. (GitHub). > It is **not a Microsoft 365 or Azure Online Service**, so it is not covered by @@ -693,7 +701,8 @@ handling of data you send to its online services is described in the The one destination contacted by default that is **not a Microsoft 365 or Azure Online Service** is the public npm registry (`https://registry.npmjs.org`, operated by npm, Inc./GitHub), contacted -at most once a day by the [update check](#update-notifications) to read the published version list for +at most once a day per running package version and registry (while the shared cache is retained) +by the [update check](#update-notifications) to read the published version list for `@microsoft/spe-mcp`. ⚠️ Because it is not a Microsoft Online Service, it is **not covered by the Microsoft Product Terms, the Microsoft Products and Services Data Protection Addendum (DPA), or the EU Data Boundary**, and it is **outside the Microsoft 365 / Azure compliance diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index 0713d44..db56780 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -31,7 +31,7 @@ MCP client <--stdio--> spe-mcp-server (local process) <--HTTPS--> Microsoft | Microsoft Graph (`graph.microsoft.com`) | Create/manage app registrations, container types, containers, and content | Your delegated token | The requests you invoke, in your tenant | Microsoft first-party, in-tenant | | Azure Resource Manager (`management.azure.com`) | Register the `Microsoft.Syntex` provider and wire SPE billing to your subscription | Your Azure token | ARM requests in your subscription | Microsoft first-party, in-subscription | | Microsoft Learn MCP (`learn.microsoft.com/api/mcp`) | Read-only public documentation lookup (`docs_search`) | **None** | Documentation queries only — **no customer data** | Microsoft first-party, public docs | -| Public npm registry (`registry.npmjs.org`, default) | Update check: read the published version list for `@microsoft/spe-mcp` at most once per 24 h (control **SEC-008**) | **None** | Package name in the request path only. Necessarily discloses your **IP address**, the static **`User-Agent`** `spe-mcp-server/`, **standard TLS/HTTP connection metadata** (TLS handshake and SNI, `Host`/`Accept` headers, request timing), and the request time. Opting out of telemetry suppresses this request entirely. **No** customer data, tenant/user/subscription identifier, install GUID, machine name, session ID, or usage data | ⚠️ **Third party (npm, Inc. / GitHub) — not a Microsoft 365 or Azure Online Service; OUTSIDE the Microsoft 365 / Azure compliance boundary and not covered by the Microsoft Product Terms, DPA, or EUDB** | +| Public npm registry (`registry.npmjs.org`, default) | Update check: read the published version list for `@microsoft/spe-mcp` at most once per 24 h for the same running package version and registry while the shared cache is retained (control **SEC-008**) | **None** | Package name in the request path only. Necessarily discloses your **IP address**, the static **`User-Agent`** `spe-mcp-server/`, **standard TLS/HTTP connection metadata** (TLS handshake and SNI, `Host`/`Accept` headers, request timing), and the request time. Opting out of telemetry suppresses this request entirely. **No** customer data, tenant/user/subscription identifier, install GUID, machine name, session ID, or usage data | ⚠️ **Third party (npm, Inc. / GitHub) — not a Microsoft 365 or Azure Online Service; OUTSIDE the Microsoft 365 / Azure compliance boundary and not covered by the Microsoft Product Terms, DPA, or EUDB** | | Configured registry (`SPE_NPM_REGISTRY`, when set) | Same update check, sent to the configured HTTPS endpoint instead of the public npm registry | **None** | Same minimal request shape and observable connection metadata as above | **Configuration-dependent.** The operator, terms, data handling, and compliance boundary are determined by the configured endpoint; the server does not classify it as npm/GitHub or assign it to a boundary | By default, two calls use public endpoints outside your tenant, and neither carries customer data: @@ -74,6 +74,21 @@ These never leave your machine: have already been notified about — **no identifiers of any kind**. It is **retained until you delete it**; `spe-mcp logout` and `spe-mcp auth --reset` remove it, and `status_get` prints its full path. +- The transient owner-only **update-check lock files** (`update-check.json.lock`, plus a recovery + lock only while reclaiming an abandoned lock). They contain only the local process ID and + lock-acquisition timestamp, serialize stale-cache refreshes and cache suppression writes + across processes sharing the data directory, and are removed after use (or reclaimed after 30 + seconds once the recorded process is no longer alive). Neither value is transmitted or copied + to the persistent cache. The refresh-lock owner records the 24-hour attempt in the cache + before egress. An abrupt exit while atomically publishing a lock can leave an owner-only + `.tmp-*` lock file; the next eligible check cleans it after the same liveness/30-second test, + or it remains local until the data directory is deleted. +- The owner-only **update-check deletion generation** (`update-check.json.deleted`), containing + only a timestamp. Logout/reset advances it before deleting the cached registry result so an + in-flight refresh cannot recreate that result afterward. It is not transmitted and remains + until the data directory is deleted. An abrupt exit during atomic cache replacement can also + leave an owner-only `update-check.json.tmp-*` file; the next eligible check removes it after + 30 seconds, and logout/reset removes it immediately. - **stderr** diagnostic logs, with tokens and secrets redacted (`src/logging.ts`). ## Compliance boundary and EU Data Boundary (EUDB) diff --git a/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md index 0f9f257..e480b30 100644 --- a/docs/SECURITY-CONTROLS.md +++ b/docs/SECURITY-CONTROLS.md @@ -24,7 +24,7 @@ that maps each code to a human-readable name and a one-line description. | SEC-002 | Client-safe error messages | Tool `catch` blocks surface only sanitized, consistent messages to clients; internal detail stays in server-side logs. | | SEC-003 | Secure filesystem (owner-only) | Credential and state files (token cache, server state) are written owner-only (POSIX `0o600`; ACL-governed on Windows). | | SEC-007 | Docs endpoint validation | The Microsoft Learn MCP endpoint is resolved and validated before use to prevent redirection to an untrusted host. | -| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data), discloses only what any HTTPS connection reveals (IP address, the static product `User-Agent`, and standard TLS/HTTP connection metadata such as the TLS handshake/SNI, `Host`/`Accept` headers, and request timing), is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), cached owner-only via SEC-003 with a 24 h TTL (a failed check backs off for the same 24 h, so at most one request per day either way) and deleted on `logout` / `auth --reset`, announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK` (legacy alias), `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false` — the telemetry opt-out suppresses the registry request entirely rather than merely omitting the `User-Agent`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | +| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path per refresh (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data), discloses only what any HTTPS connection reveals (IP address, the static product `User-Agent`, and standard TLS/HTTP connection metadata such as the TLS handshake/SNI, `Host`/`Accept` headers, and request timing), is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), and cached owner-only via SEC-003 with a 24 h TTL. An owner-only cross-process refresh lock serializes stale-cache refreshes, and its owner records a failure reservation before egress so concurrent starts, failed checks, and mid-request process exits still allow at most one request per 24 h for the same running package version and registry across processes sharing the retained data-directory cache. Changing that version/registry or deleting the cache starts a new window. The cache is deleted on `logout` / `auth --reset`; channel and stable notification suppression is persisted independently. The check announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK` (legacy alias), `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false` — the telemetry opt-out suppresses the registry request entirely rather than merely omitting the `User-Agent`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | > Adding a new safeguard? Give it the next code in its family and add a row here > so code comments and tests have a lookup. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ffac4a2..4090197 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -91,9 +91,11 @@ Access can be disabled later with `content_access_revoke`. ## Update notice: missing, stale, or registry unreachable -The server checks the public npm registry at most once every 24 hours and, if a newer release -exists, appends a one-line `Update available: …` notice to a single tool result. It never -blocks, never retries in-band, and **never updates itself** — there is no auto-update. +The server checks the public npm registry at most once every 24 hours for the same running +package version and registry across processes sharing the retained data-directory cache. If a +newer release exists, it appends a one-line +`Update available: …` notice to a single tool result. It never blocks, never retries in-band, +and **never updates itself** — there is no auto-update. Common situations: @@ -102,8 +104,9 @@ Common situations: opt-out is set: `SPE_MCP_UPDATE_CHECK=false` (preferred), `--no-update-check`, `SPE_NO_UPDATE_CHECK=1` (legacy alias), `NO_UPDATE_NOTIFIER=1`, or `SPE_MCP_COLLECT_TELEMETRY=false` (the telemetry opt-out suppresses the registry request - entirely; it does not merely omit the `User-Agent`). The notice is also shown only once per detected version - per cache. Run `status_get` to see the **Update check** row, which reports the exact state + entirely; it does not merely omit the `User-Agent`). Channel and stable targets are suppressed + independently, so each detected target version is shown only once per cache. Run `status_get` + to see the **Update check** row, which reports the exact state and skip reason. When skipped, **no network request, stderr notice, or cache write occurs**. - **Offline, proxied, or firewalled registry.** The lookup has a 2-second timeout and fails silently; the failure is cached so the server does not retry on every call. `status_get` @@ -128,6 +131,13 @@ Common situations: shown by `status_get`), contains **no identifier**, and is retained until removed. Delete it with `spe-mcp logout`, `spe-mcp auth --reset`, or by removing the file manually. Deleting it also forces a re-check on the next start. +- **Multiple server processes start together.** An owner-only `update-check.json.lock` file + serializes stale-cache refreshes. The lock owner records the 24-hour attempt before egress; + other processes make no request. The lock is normally removed immediately and an abandoned + regular-file lock is reclaimed after 30 seconds only after its recorded local process is no + longer alive. Lock or cache errors fail closed without contacting the registry. Changing the + running package version or registry, or deleting the cache, intentionally starts a new + 24-hour window. ## Correlation IDs diff --git a/src/secure-fs.test.ts b/src/secure-fs.test.ts index e06825e..37c9c4e 100644 --- a/src/secure-fs.test.ts +++ b/src/secure-fs.test.ts @@ -14,7 +14,13 @@ import { } from "node:fs"; import { tmpdir, platform } from "node:os"; import { join } from "node:path"; -import { ensureSecureDir, writeSecureFile, readSecureFile } from "./secure-fs.js"; +import { + ensureSecureDir, + writeSecureFile, + readSecureFile, + tryCreateSecureFileExclusive, + writeSecureFileAtomic, +} from "./secure-fs.js"; // POSIX permission bits under test, named for readability (see secure-fs.ts). // 0o700 = rwx------ (owner-only, directories) 0o600 = rw------- (owner-only, files) @@ -60,6 +66,21 @@ describe("secure-fs (SEC-003 owner-only credential/state files)", () => { expect(existsSync(file)).toBe(true); }); + it("creates an exclusive file once without overwriting it (cross-platform)", () => { + const file = join(dir, "refresh.lock"); + expect(tryCreateSecureFileExclusive(file, "first")).toBe(true); + expect(tryCreateSecureFileExclusive(file, "second")).toBe(false); + expect(readFileSync(file, "utf-8")).toBe("first"); + }); + + it("atomically creates and replaces a state file (cross-platform)", () => { + const file = join(dir, "update-check.json"); + writeSecureFileAtomic(file, "first"); + expect(readFileSync(file, "utf-8")).toBe("first"); + writeSecureFileAtomic(file, "second"); + expect(readFileSync(file, "utf-8")).toBe("second"); + }); + it.runIf(isPosix)("writes the file with owner-only (0o600) permissions", () => { const file = join(dir, "token-cache.json"); writeSecureFile(file, "data"); diff --git a/src/secure-fs.ts b/src/secure-fs.ts index 8255658..67a1d03 100644 --- a/src/secure-fs.ts +++ b/src/secure-fs.ts @@ -33,6 +33,7 @@ */ import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { chmodSync, closeSync, @@ -42,9 +43,12 @@ import { fstatSync, ftruncateSync, lstatSync, + linkSync, mkdirSync, openSync, readFileSync, + renameSync, + unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; @@ -233,6 +237,100 @@ export function writeSecureFile(path: string, data: string): void { } } +/** + * Atomically replace an owner-only state file. + * + * The complete new payload is first written and fd-validated at a random path + * in the same secure directory, then renamed over the destination. A process + * exit can therefore leave either the complete old file or the complete new + * file — never a truncated intermediate value. The existing destination is + * validated before replacement so this retains {@link writeSecureFile}'s + * fail-closed behavior for hostile final components. + */ +export function writeSecureFileAtomic(path: string, data: string): void { + const temp = `${path}.tmp-${randomUUID()}`; + try { + writeSecureFile(temp, data); + // Validate an existing destination without following a final symlink. A + // missing destination is the normal first-write case. + readSecureFile(path); + renameSync(temp, path); + } finally { + try { + unlinkSync(temp); + } catch { + // The rename normally consumed it. If writing/renaming failed, cleanup is + // best-effort; the owner-only random temp file contains the same local + // state payload as the destination. + } + } +} + +/** + * Atomically create a new owner-only file, returning `false` when the path + * already exists. + * + * This is the secure-fs equivalent of `open(..., "wx")`: `O_EXCL` supplies the + * cross-process create-if-absent primitive while the same `O_NOFOLLOW`, fd + * ownership/type validation, and owner-only permissions as {@link + * writeSecureFile} keep caller-controlled data directories fail-closed. + * + * The parent directory must already have been validated with + * {@link ensureSecureDir}. Any error other than an existing path is thrown so + * callers can fail closed rather than mistaking an insecure/unwritable path for + * lock contention. + */ +export function tryCreateSecureFileExclusive(path: string, data: string): boolean { + // Publish a fully written inode with an atomic hard-link create. Opening the + // final path with O_EXCL and then writing would leave a short (or, if the + // process is paused, unbounded) interval where another process can observe a + // malformed/empty lock and mistake it for abandoned state. + const temp = `${path}.tmp-${randomUUID()}`; + const flags = + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW; + try { + let fd: number; + try { + fd = openSync(temp, flags, OWNER_RW); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ELOOP") { + throw insecureFile(temp, "it is a symlink"); + } + throw err; + } + + try { + if (IS_POSIX) { + const st = fstatSync(fd); + if (!st.isFile()) throw insecureFile(temp, "it is not a regular file"); + const uid = process.getuid?.(); + if (uid !== undefined && st.uid !== uid) { + throw insecureFile(temp, "it is owned by another user"); + } + fchmodSync(fd, OWNER_RW); + } + writeFileSync(fd, data, "utf-8"); + } finally { + closeSync(fd); + } + + try { + linkSync(temp, path); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "EEXIST") return false; + throw err; + } + } finally { + try { + unlinkSync(temp); + } catch { + // Best-effort cleanup. The random owner-only temp file contains only the + // same non-secret synchronization payload as the published file. + } + } +} + /** * Read a credential/state file, opening with `O_NOFOLLOW` and verifying the fd * (regular file, owner) before reading. Returns `null` when the file does not diff --git a/src/update-check.test.ts b/src/update-check.test.ts index 3eef3f2..46d3959 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -11,7 +11,15 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,6 +30,7 @@ import { CHECK_TTL_MS, FAILURE_BACKOFF_MS, MAX_RESPONSE_BYTES, + REFRESH_LOCK_STALE_MS, REQUEST_TIMEOUT_MS, __testing, getUpdateStatus, @@ -50,6 +59,10 @@ const NEWER_STABLE = `${CURRENT.major + 1}.0.0`; const NEWER_CHANNEL = CHANNEL ? `${CURRENT.major + 1}.0.0-${CHANNEL}.1` : null; /** What the check should settle on: the user's own channel, else stable. */ const EXPECTED_LATEST = NEWER_CHANNEL ?? NEWER_STABLE; +const EXPECTED_NOTIFICATION_KEYS = [ + ...(CHANNEL && NEWER_CHANNEL ? [`channel:${CHANNEL}:${NEWER_CHANNEL}`] : []), + `stable:${NEWER_STABLE}`, +]; /** A registry payload offering a newer build on both `latest` and the channel. */ function tagsFixture(): Record { @@ -125,6 +138,17 @@ function readCacheFile(): Record { return JSON.parse(readFileSync(getUpdateCacheFile(), "utf8")) as Record; } +function mockExitedProcess(pid: number): void { + vi.spyOn(process, "kill").mockImplementation( + ((candidate: number) => { + if (candidate === pid) { + throw Object.assign(new Error("no such process"), { code: "ESRCH" }); + } + return true; + }) as typeof process.kill, + ); +} + // --------------------------------------------------------------------------- // Skip reasons // --------------------------------------------------------------------------- @@ -466,6 +490,7 @@ describe("renderNotice", () => { package: PACKAGE_NAME, current: "1.0.0-alpha.1", latest: "1.0.0-alpha.2", + target: "channel", channel: "alpha", packageSpec: `${PACKAGE_NAME}@alpha`, }); @@ -484,6 +509,7 @@ describe("renderNotice", () => { package: PACKAGE_NAME, current: "1.0.0-alpha.1", latest: "1.0.0-alpha.2", + target: "channel", channel: "alpha", packageSpec: `${PACKAGE_NAME}@alpha`, }); @@ -507,6 +533,7 @@ describe("renderNotice", () => { package: PACKAGE_NAME, current: "1.0.0-alpha.1", latest: "1.0.0-alpha.2", + target: "channel", channel: "alpha", packageSpec: `${PACKAGE_NAME}@alpha`, }); @@ -525,6 +552,7 @@ describe("renderNotice", () => { package: PACKAGE_NAME, current: "1.0.0-alpha.1", latest: "1.0.0-alpha.2", + target: "channel", channel: "alpha", stable: "2.0.0", packageSpec: `${PACKAGE_NAME}@alpha`, @@ -539,6 +567,7 @@ describe("renderNotice", () => { package: PACKAGE_NAME, current: "1.0.0", latest: "1.1.0", + target: "stable", channel: null, packageSpec: `${PACKAGE_NAME}@latest`, }); @@ -570,7 +599,9 @@ describe("runUpdateCheck", () => { // Take-and-clear: a second consumer must not see it again. expect(takePendingUpdateNotice()).toBeNull(); - expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + expect(readCacheFile()["notifiedFor"]).toEqual([ + ...EXPECTED_NOTIFICATION_KEYS, + ]); }); it("requests the abbreviated packument with the product user agent and no credentials", async () => { @@ -610,6 +641,88 @@ describe("runUpdateCheck", () => { expect(takePendingUpdateNotice()).toBeNull(); }); + it.runIf(CHANNEL === "alpha")( + "announces GA when alpha is unchanged after an earlier alpha notice", + async () => { + const alpha2 = `${CURRENT.major}.${CURRENT.minor}.${CURRENT.patch}-alpha.2`; + const ga = `${CURRENT.major + 1}.0.0`; + + respondWith(packument({ alpha: alpha2 })); + await __testing.runUpdateCheck({}); + const alphaNotice = takePendingUpdateNotice(); + expect(alphaNotice?.updateAvailable).toMatchObject({ + latest: alpha2, + target: "channel", + channel: "alpha", + }); + expect(readCacheFile()["notifiedFor"]).toEqual([`channel:alpha:${alpha2}`]); + + // Restart after the TTL. The alpha target is unchanged, but `latest` has + // advanced to GA. Stable suppression must be independent from alpha. + __testing.reset(); + __testing.setInstalled(true); + const stale = readCacheFile(); + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ ...stale, checkedAt: Date.now() - CHECK_TTL_MS }), + "utf8", + ); + respondWith(packument({ alpha: alpha2, latest: ga })); + + await __testing.runUpdateCheck({}); + const gaNotice = takePendingUpdateNotice(); + expect(gaNotice?.updateAvailable).toMatchObject({ + latest: ga, + target: "stable", + channel: "alpha", + }); + expect(gaNotice?.updateAvailable.stable).toBeUndefined(); + expect(gaNotice?.text).toContain(`${PACKAGE_VERSION} -> ${ga}`); + expect(gaNotice?.text).not.toContain(alpha2); + expect(gaNotice?.text).not.toContain("(alpha channel)"); + expect(readCacheFile()["notifiedFor"]).toEqual([ + `channel:alpha:${alpha2}`, + `stable:${ga}`, + ]); + }, + ); + + it("never labels a prerelease value from the latest tag as stable", async () => { + const prereleaseLatest = `${CURRENT.major + 2}.0.0-rc.1`; + respondWith( + packument({ + latest: prereleaseLatest, + ...(CHANNEL && NEWER_CHANNEL ? { [CHANNEL]: NEWER_CHANNEL } : {}), + }), + ); + + await __testing.runUpdateCheck({}); + + const status = getUpdateStatus(); + expect(status.updateAvailable?.stable).toBeUndefined(); + expect(status.updateAvailable?.stablePackageSpec).toBeUndefined(); + expect(takePendingUpdateNotice()?.text).not.toContain("Latest stable release"); + if (!CHANNEL) expect(status.state).toBe("up-to-date"); + }); + + it("does not expose a cached prerelease latest tag as a stable fallback", () => { + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ + version: 1, + checkedAt: Date.now(), + currentVersion: PACKAGE_VERSION, + registry: DEFAULT_REGISTRY, + outcome: "success", + latest: `${CURRENT.major + 2}.0.0-rc.1`, + notifiedFor: [], + }), + "utf8", + ); + + expect(getUpdateStatus().latestVersion).toBeUndefined(); + }); + it("reports up-to-date when the registry offers nothing newer", async () => { respondWith(packument({ latest: PACKAGE_VERSION, ...(CHANNEL ? { [CHANNEL]: PACKAGE_VERSION } : {}) })); @@ -676,6 +789,168 @@ describe("runUpdateCheck", () => { expect(getUpdateStatus().state).toBe("unavailable"); }); + it("reserves the 24-hour request window before egress and blocks a concurrent process", async () => { + let finishFetch: ((response: Response) => void) | undefined; + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + + const first = __testing.runUpdateCheck({}); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + // The failure reservation exists before fetch resolves. A second module + // instance/process sharing this data directory sees it as fresh and does + // not issue another request. + expect(readCacheFile()).toMatchObject({ + outcome: "failure", + currentVersion: PACKAGE_VERSION, + registry: DEFAULT_REGISTRY, + }); + await __testing.runUpdateCheck({}); + expect(fetchMock).toHaveBeenCalledTimes(1); + + finishFetch?.(new Response(packument(tagsFixture()), { status: 200 })); + await first; + expect(readCacheFile()["outcome"]).toBe("success"); + expect(existsSync(__testing.getRefreshLockFile())).toBe(false); + }); + + it("fails closed without egress while another process holds an active refresh lock", async () => { + writeFileSync(__testing.getRefreshLockFile(), "other-process", "utf8"); + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(cacheExists()).toBe(false); + expect(getUpdateStatus().state).toBe("unavailable"); + expect(readFileSync(__testing.getRefreshLockFile(), "utf8")).toBe("other-process"); + }); + + it("reclaims an abandoned stale refresh lock and completes one request", async () => { + const lock = __testing.getRefreshLockFile(); + const createdAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; + const deadPid = 999_999_991; + mockExitedProcess(deadPid); + writeFileSync(lock, JSON.stringify({ pid: deadPid, createdAt }), "utf8"); + const staleTime = new Date(createdAt); + utimesSync(lock, staleTime, staleTime); + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readCacheFile()["outcome"]).toBe("success"); + expect(existsSync(lock)).toBe(false); + }); + + it("fails closed on stale malformed lock metadata", async () => { + const lock = __testing.getRefreshLockFile(); + writeFileSync(lock, "partially-written", "utf8"); + const staleTime = new Date(Date.now() - REFRESH_LOCK_STALE_MS - 1_000); + utimesSync(lock, staleTime, staleTime); + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(getUpdateStatus().state).toBe("unavailable"); + expect(existsSync(lock)).toBe(true); + }); + + it("never replaces an old lock while its recorded process is still alive", async () => { + const lock = __testing.getRefreshLockFile(); + writeFileSync( + lock, + JSON.stringify({ + pid: process.pid, + createdAt: Date.now() - REFRESH_LOCK_STALE_MS - 1_000, + }), + "utf8", + ); + const staleTime = new Date(Date.now() - REFRESH_LOCK_STALE_MS - 1_000); + utimesSync(lock, staleTime, staleTime); + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(getUpdateStatus().state).toBe("unavailable"); + expect(existsSync(lock)).toBe(true); + }); + + it("does not remove or bypass a non-file refresh lock entry", async () => { + mkdirSync(__testing.getRefreshLockFile()); + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(getUpdateStatus().state).toBe("unavailable"); + expect(existsSync(__testing.getRefreshLockFile())).toBe(true); + }); + + it("does not acquire or egress while stale-lock recovery is active", async () => { + writeFileSync(__testing.getRefreshRecoveryLockFile(), "other-reclaimer", "utf8"); + respondWith(packument(tagsFixture())); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(getUpdateStatus().state).toBe("unavailable"); + expect(existsSync(__testing.getRefreshLockFile())).toBe(false); + }); + + it("reclaims an abandoned recovery lock before acquiring the refresh lock", () => { + const recovery = __testing.getRefreshRecoveryLockFile(); + const createdAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; + const deadPid = 999_999_992; + mockExitedProcess(deadPid); + writeFileSync(recovery, JSON.stringify({ pid: deadPid, createdAt }), "utf8"); + const staleTime = new Date(createdAt); + utimesSync(recovery, staleTime, staleTime); + + const token = __testing.acquireRefreshLock(Date.now()); + + expect(token).not.toBeNull(); + expect(existsSync(recovery)).toBe(false); + expect(existsSync(__testing.getRefreshLockFile())).toBe(true); + __testing.releaseRefreshLock(token as string); + expect(existsSync(__testing.getRefreshLockFile())).toBe(false); + }); + + it("cleans an abandoned atomic-publication temp lock on the next acquisition", () => { + const createdAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; + const deadPid = 999_999_993; + mockExitedProcess(deadPid); + const tempLock = `${__testing.getRefreshLockFile()}.tmp-abandoned`; + writeFileSync(tempLock, JSON.stringify({ pid: deadPid, createdAt }), "utf8"); + const staleTime = new Date(createdAt); + utimesSync(tempLock, staleTime, staleTime); + + const token = __testing.acquireRefreshLock(Date.now()); + + expect(token).not.toBeNull(); + expect(existsSync(tempLock)).toBe(false); + __testing.releaseRefreshLock(token as string); + }); + + it("cleans an abandoned atomic cache temp on the next acquisition", () => { + const tempCache = `${getUpdateCacheFile()}.tmp-abandoned`; + writeFileSync(tempCache, '{"partial":true}', "utf8"); + const staleTime = new Date(Date.now() - REFRESH_LOCK_STALE_MS - 1_000); + utimesSync(tempCache, staleTime, staleTime); + + const token = __testing.acquireRefreshLock(Date.now()); + + expect(token).not.toBeNull(); + expect(existsSync(tempCache)).toBe(false); + __testing.releaseRefreshLock(token as string); + }); + it.each([ [ "a network error", @@ -822,7 +1097,7 @@ describe("runUpdateCheck", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); - it("never throws even when the cache directory is unusable", async () => { + it("fails closed without egress when the cache directory is unusable", async () => { respondWith(packument(tagsFixture())); // Point the data dir at a path whose parent is a file: every write fails. const blocker = join(dataDir, "blocker"); @@ -830,7 +1105,8 @@ describe("runUpdateCheck", () => { setDataDirOverride(join(blocker, "nested")); await expect(__testing.runUpdateCheck({})).resolves.toBeUndefined(); - expect(getUpdateStatus().state).toBe("update-available"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(getUpdateStatus().state).toBe("unavailable"); }); }); @@ -1235,9 +1511,9 @@ describe("privacy: cache retention and deletion", () => { }).not.toThrow(); }); - // Deleting the cache is a privacy promise: after logout there must be no - // residue, and delivering a notice that was already in flight must not quietly - // recreate the file the user just asked to be removed. + // Deleting the cached registry result is a privacy promise. A small local + // deletion-generation tombstone may remain to prevent in-flight writers from + // quietly recreating the result the user asked to remove. it("does not recreate the cache when a pending notice is delivered after deletion", async () => { respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); @@ -1251,6 +1527,31 @@ describe("privacy: cache retention and deletion", () => { expect(cacheExists(), "delivery must not resurrect a deleted cache").toBe(false); }); + it("does not recreate the cache when logout races an in-flight registry request", async () => { + let finishFetch: ((response: Response) => void) | undefined; + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + + const check = __testing.runUpdateCheck({}); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(cacheExists(), "pre-egress reservation should exist").toBe(true); + + removeUpdateCache(); + expect(cacheExists()).toBe(false); + expect(existsSync(__testing.getDeletionGenerationFile())).toBe(true); + + finishFetch?.(new Response(packument(tagsFixture()), { status: 200 })); + await check; + + expect(cacheExists(), "post-fetch write must not undo logout").toBe(false); + expect(takePendingUpdateNotice()).toBeNull(); + expect(getUpdateStatus().state).toBe("unavailable"); + }); + // The CLI is where the deletion is actually triggered. Spawning `logout` would // touch real credential state, so assert the wiring statically instead: both // credential-clearing paths must call the cache removal. @@ -1320,7 +1621,8 @@ describe("privacy: status_get reporting is offline", () => { // --------------------------------------------------------------------------- // Code review follow-ups: the notice is only "spent" once a caller has actually -// received it, one probe per process, and hostile cache content stays inert. +// received it, refreshes are serialized across processes, and hostile cache +// content stays inert. // --------------------------------------------------------------------------- describe("notice delivery is what marks a version as notified", () => { @@ -1339,7 +1641,7 @@ describe("notice delivery is what marks a version as notified", () => { expect(readCacheFile()["notifiedFor"]).toEqual([]); expect(takePendingUpdateNotice()).not.toBeNull(); - expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + expect(readCacheFile()["notifiedFor"]).toEqual(EXPECTED_NOTIFICATION_KEYS); }); it("survives a process exit before the notice was delivered", async () => { @@ -1354,7 +1656,7 @@ describe("notice delivery is what marks a version as notified", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const notice = takePendingUpdateNotice(); expect(notice?.updateAvailable.latest).toBe(EXPECTED_LATEST); - expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + expect(readCacheFile()["notifiedFor"]).toEqual(EXPECTED_NOTIFICATION_KEYS); }); it("keeps replaying the notice until one restart actually delivers it", async () => { @@ -1367,7 +1669,7 @@ describe("notice delivery is what marks a version as notified", () => { } expect(takePendingUpdateNotice()).not.toBeNull(); - expect(readCacheFile()["notifiedFor"]).toEqual([EXPECTED_LATEST]); + expect(readCacheFile()["notifiedFor"]).toEqual(EXPECTED_NOTIFICATION_KEYS); }); it("merges with the cache written by another process before delivery", async () => { @@ -1379,7 +1681,7 @@ describe("notice delivery is what marks a version as notified", () => { writeFileSync(getUpdateCacheFile(), JSON.stringify(concurrent), "utf8"); expect(takePendingUpdateNotice()).not.toBeNull(); - expect(readCacheFile()["notifiedFor"]).toEqual(["7.7.7", EXPECTED_LATEST]); + expect(readCacheFile()["notifiedFor"]).toEqual(["7.7.7", ...EXPECTED_NOTIFICATION_KEYS]); }); it("does not recreate a cache that logout deleted", async () => { diff --git a/src/update-check.ts b/src/update-check.ts index dd9f159..b857cd3 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -66,8 +66,13 @@ * strict SemVer parser before it is compared or shown. * - Results are cached under the server data dir with the same owner-only * secure-fs primitives as the token cache (SEC-003: 0700 dir / 0600 file, no - * symlink traversal), with a 24h TTL applied to successes *and* failures so - * the "at most one request per day" promise holds even offline. + * symlink traversal), with a 24h TTL applied to successes *and* failures. + * - A cross-process exclusive lock serializes stale-cache refreshes. The process + * that owns it writes a 24h failure reservation BEFORE egress, then replaces + * that reservation with success data after a valid response. For the same + * running package version and registry, concurrent processes, crashes after + * egress, lock errors, and stale-lock recovery therefore cannot cause a + * second request inside the reservation window while the cache is retained. * * KNOWN LIMITATION (accepted tradeoff, not a sign-off) * - Node's built-in `fetch` does not honour `HTTP_PROXY` / `HTTPS_PROXY` / @@ -75,10 +80,6 @@ * which this package deliberately does not take. On a proxy-only network the * probe simply fails closed (silent no-op) rather than bypassing the proxy. * Operators who must not egress at all should turn the check off outright. - * - Cache writes are last-writer-wins. `secure-fs` has no compare-and-swap, and - * this change deliberately does not alter that shared primitive. Two servers - * delivering a notice at the same instant can therefore drop one suppression - * entry, costing at most one extra notice; tracked as follow-up work. * * ZERO-NETWORK OPT-OUTS — each skips the check entirely (no request, no notice, * no cache read, no cache write): `--no-update-check`, `SPE_MCP_UPDATE_CHECK=false`, @@ -86,12 +87,18 @@ * `SPE_MCP_COLLECT_TELEMETRY=false`, any CI marker, and source checkouts. */ -import { existsSync, unlinkSync } from "node:fs"; +import { existsSync, lstatSync, readdirSync, unlinkSync } from "node:fs"; +import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createLogger } from "./logger.js"; import { getDataDir, getUpdateCacheFile } from "./paths.js"; -import { ensureSecureDir, readSecureFile, writeSecureFile } from "./secure-fs.js"; +import { + ensureSecureDir, + readSecureFile, + tryCreateSecureFileExclusive, + writeSecureFileAtomic, +} from "./secure-fs.js"; import { isNewer, parseSemver, releaseChannel, type SemVer } from "./semver.js"; import { applyProductUserAgent } from "./user-agent.js"; import { PACKAGE_NAME, PACKAGE_VERSION } from "./version.js"; @@ -111,13 +118,22 @@ export const CHECK_TTL_MS = 24 * 60 * 60 * 1000; /** * How long a failed probe is remembered before retrying. * - * Deliberately identical to {@link CHECK_TTL_MS}: the documented promise is "at - * most one request per day", and a shorter failure backoff would quietly make - * that promise false for anyone who is offline (a failing probe would be retried - * several times a day). Success and failure are both remembered for 24h. + * Deliberately identical to {@link CHECK_TTL_MS}: for the same package version + * and registry, a shorter failure backoff would quietly retry several times a + * day while offline. Success and failure are both remembered for 24h. */ export const FAILURE_BACKOFF_MS = CHECK_TTL_MS; +/** + * Age after which an abandoned refresh lock can be reclaimed. + * + * A normal request is hard-capped at two seconds. Thirty seconds leaves ample + * room for scheduling and cache I/O without allowing a crashed process to block + * refreshes forever. The pre-request cache reservation below remains the + * authoritative 24-hour request gate even when a stale lock is reclaimed. + */ +export const REFRESH_LOCK_STALE_MS = 30_000; + /** Cap on remembered "already told the user about this version" entries. */ const MAX_NOTIFIED_ENTRIES = 10; @@ -217,6 +233,8 @@ export interface UpdateAvailable { readonly current: string; /** The newest version on the user's own channel (or stable, when on stable). */ readonly latest: string; + /** Whether {@link latest} is the running prerelease channel or stable target. */ + readonly target: "channel" | "stable"; /** Release channel of the running build (`alpha`, `beta`, …), or `null`. */ readonly channel: string | null; /** Newest STABLE release, when it is also newer than the running build. */ @@ -281,11 +299,19 @@ interface UpdateCache { notifiedFor: string[]; } +type NoticeTarget = + | { readonly kind: "channel"; readonly channel: string; readonly version: string } + | { readonly kind: "stable"; readonly version: string }; + +interface PendingUpdateNotice extends UpdateNotice { + readonly suppressionKeys: readonly string[]; +} + // --------------------------------------------------------------------------- // Process-local state // --------------------------------------------------------------------------- -let pendingNotice: UpdateNotice | null = null; +let pendingNotice: PendingUpdateNotice | null = null; let status: UpdateCheckStatus = { enabled: true, state: "pending", currentVersion: PACKAGE_VERSION }; let inFlight: Promise | null = null; /** Test-only override for "am I running from an installed package?". */ @@ -617,19 +643,281 @@ function readCache(): UpdateCache | null { } } -/** Persist the cache with owner-only permissions. Failures are non-fatal. */ -function writeCache(cache: UpdateCache): void { +/** Persist the cache with owner-only permissions. Returns false on any refusal. */ +function writeCache(cache: UpdateCache): boolean { try { ensureSecureDir(getDataDir()); - writeSecureFile( + writeSecureFileAtomic( getUpdateCacheFile(), JSON.stringify({ ...cache, notifiedFor: cache.notifiedFor.slice(-MAX_NOTIFIED_ENTRIES) }, null, 2), ); + return true; + } catch { + return false; + } +} + +/** Persistent local generation used to cancel writes that raced cache deletion. */ +function getDeletionGenerationFile(): string { + return `${getUpdateCacheFile()}.deleted`; +} + +/** Read the current deletion generation. Corruption fails closed as a new value. */ +function readDeletionGeneration(): string | null { + try { + return readSecureFile(getDeletionGenerationFile()); } catch { - // Best-effort: an unwritable cache only costs an extra probe next time. + return "unreadable"; } } +/** + * Advance the deletion generation before removing the cache. + * + * A refresh captures the prior value under its lock and verifies it both before + * and after each atomic cache replacement. A logout that races the replacement + * therefore wins: the refresh removes its own write and never resurrects data. + */ +function advanceDeletionGeneration(): boolean { + try { + ensureSecureDir(getDataDir()); + const current = readDeletionGeneration(); + const parsed = current === null ? 0 : Number(current); + const next = Math.max( + Date.now(), + Number.isSafeInteger(parsed) && parsed >= 0 ? parsed + 1 : 1, + ); + writeSecureFileAtomic(getDeletionGenerationFile(), String(next)); + return true; + } catch { + return false; + } +} + +function removeCacheFileOnly(): void { + try { + unlinkSync(getUpdateCacheFile()); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + } +} + +/** + * Atomically persist only while logout's deletion generation is unchanged. + * Called under the refresh lock, so deleting our mismatched write cannot remove + * a successor refresh's cache. + */ +function writeCacheForGeneration( + cache: UpdateCache, + deletionGeneration: string | null, +): boolean { + if (readDeletionGeneration() !== deletionGeneration) return false; + if (!writeCache(cache)) return false; + if (readDeletionGeneration() === deletionGeneration) return true; + try { + removeCacheFileOnly(); + } catch { + // The deletion generation still prevents this process from writing again. + } + return false; +} + +/** Ephemeral owner-only lock beside the update cache. */ +function getRefreshLockFile(): string { + return `${getUpdateCacheFile()}.lock`; +} + +/** Serializes recovery of an abandoned refresh lock. */ +function getRefreshRecoveryLockFile(): string { + return `${getRefreshLockFile()}.recovery`; +} + +/** + * Whether an owner-only lock path currently exists. A security/read error is + * treated as "present" so acquisition fails closed. + */ +function secureLockExists(file: string): boolean { + try { + return readSecureFile(file) !== null; + } catch { + return true; + } +} + +/** Parsed ownership data for one of this module's transient lock files. */ +function lockOwner(raw: string): { pid: number; createdAt: number } | null { + try { + const parsed = JSON.parse(raw) as Record; + if ( + typeof parsed !== "object" || + parsed === null || + !Number.isSafeInteger(parsed["pid"]) || + (parsed["pid"] as number) <= 0 || + typeof parsed["createdAt"] !== "number" || + !Number.isFinite(parsed["createdAt"]) + ) { + return null; + } + return { pid: parsed["pid"] as number, createdAt: parsed["createdAt"] }; + } catch { + return null; + } +} + +/** + * Whether the local process recorded in a lock may still be alive. + * + * Anything except an explicit ESRCH/"no such process" result is treated as + * alive. In particular, EPERM means the OS knows the process but will not let + * us signal it. This conservative check prevents a paused stale owner from + * resuming and deleting a replacement lock. + */ +function lockOwnerMayBeAlive(raw: string): boolean { + const owner = lockOwner(raw); + // Malformed metadata can be a partially migrated/corrupt/hostile file. There + // is no proof its creator is gone, so fail closed rather than reclaim it. + if (!owner) return true; + if (owner.pid === process.pid) return true; + try { + process.kill(owner.pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException)?.code !== "ESRCH"; + } +} + +/** + * Remove abandoned temp inodes left if a process exited while atomically + * publishing a lock. These names are never synchronization points, so deleting + * an old malformed temp is fail-closed: at worst its still-live creator's link + * operation fails and no request occurs. + */ +function cleanupAbandonedUpdateTemps(now: number): void { + const dir = getDataDir(); + const prefixes = [ + `${basename(getUpdateCacheFile())}.tmp-`, + `${basename(getRefreshLockFile())}.tmp-`, + `${basename(getRefreshRecoveryLockFile())}.tmp-`, + ]; + try { + for (const name of readdirSync(dir)) { + if (!prefixes.some((prefix) => name.startsWith(prefix))) continue; + const file = join(dir, name); + try { + const stat = lstatSync(file); + if (stat.isSymbolicLink() || !stat.isFile()) continue; + const age = now - stat.mtimeMs; + if (!Number.isFinite(age) || age < REFRESH_LOCK_STALE_MS) continue; + + const raw = readSecureFile(file); + if (raw === null) continue; + const owner = lockOwner(raw); + if (owner && lockOwnerMayBeAlive(raw)) continue; + unlinkSync(file); + } catch { + // Best-effort local hygiene; temp cleanup must never enable egress. + } + } + } catch { + // Missing/unreadable directory: acquisition below fails closed as usual. + } +} + +/** + * Remove an abandoned regular-file lock, but never follow or remove a symlink + * or non-file entry. Returns true only when the path is absent afterward. + * + * Refresh-lock callers invoke this while holding the recovery lock. Normal + * acquirers check that recovery lock both before and after creating the refresh + * lock, closing the stale-observer/replacement race. + */ +function removeStaleLock(file: string, now: number): boolean { + try { + const first = readSecureFile(file); + if (first === null) return true; + + const stat = lstatSync(file); + if (stat.isSymbolicLink() || !stat.isFile()) return false; + const age = now - stat.mtimeMs; + if (!Number.isFinite(age) || age < REFRESH_LOCK_STALE_MS) return false; + if (lockOwnerMayBeAlive(first)) return false; + + // Re-read the ownership token immediately before unlinking. This narrows + // the release/reacquire race so an old observer does not remove a successor + // process's lock. + if (readSecureFile(file) !== first) return false; + unlinkSync(file); + return true; + } catch (error) { + // A concurrent owner may have released the lock after our first check. + return (error as NodeJS.ErrnoException)?.code === "ENOENT"; + } +} + +/** + * Atomically acquire the cross-process refresh lock. + * + * Any filesystem/security error fails closed: no lock means no registry + * request. A stale lock is reclaimed once and acquisition is retried. + */ +function acquireRefreshLock(now: number): string | null { + const file = getRefreshLockFile(); + const recoveryFile = getRefreshRecoveryLockFile(); + // The local PID is used only to prove that a stale lock owner is gone before + // replacement. It is transient, never enters the cache/request, and is + // disclosed in PRIVACY.md. + const token = JSON.stringify({ pid: process.pid, createdAt: now }); + try { + ensureSecureDir(getDataDir()); + cleanupAbandonedUpdateTemps(now); + + // A crashed recovery operation must not block forever. Multiple contenders + // may observe it as stale, but each revalidates the main lock under whichever + // recovery lock it obtains; a newly created main lock is never stale. + if (secureLockExists(recoveryFile) && !removeStaleLock(recoveryFile, now)) { + return null; + } + + // Fast path. Re-check recovery AFTER the atomic create: if a stale-lock + // reclaimer raced us, surrender our lock before any cache write or egress. + if (tryCreateSecureFileExclusive(file, token)) { + if (secureLockExists(recoveryFile)) { + releaseLock(file, token); + return null; + } + return token; + } + + const recoveryToken = token; + if (!tryCreateSecureFileExclusive(recoveryFile, recoveryToken)) return null; + try { + // Revalidate under the recovery lock. If another contender replaced the + // stale lock, its fresh mtime prevents us from removing it. + if (!removeStaleLock(file, now)) return null; + return tryCreateSecureFileExclusive(file, token) ? token : null; + } finally { + releaseLock(recoveryFile, recoveryToken); + } + } catch { + return null; + } +} + +/** Release only the lock at `file` created with `token`. */ +function releaseLock(file: string, token: string): void { + try { + if (readSecureFile(file) !== token) return; + unlinkSync(file); + } catch { + // Best-effort. A surviving lock is reclaimed after REFRESH_LOCK_STALE_MS. + } +} + +/** Release only the refresh lock created by this caller. */ +function releaseRefreshLock(token: string): void { + releaseLock(getRefreshLockFile(), token); +} + /** Whether a cache entry is still authoritative for `registry` and this build. */ function isCacheFresh(cache: UpdateCache, registry: string, now: number): boolean { if (cache.currentVersion !== PACKAGE_VERSION) return false; @@ -642,15 +930,34 @@ function isCacheFresh(cache: UpdateCache, registry: string, now: number): boolea /** * Delete the local update-check cache. * - * Wired into `spe-mcp logout` (and `auth --reset`) so signing out clears every - * file this server wrote under the data directory, not just the token cache. - * Best-effort and never throws: a missing or unremovable file is not an error. + * Wired into `spe-mcp logout` (and `auth --reset`) so signing out clears the + * cached registry result and cancels in-flight cache writers. Best-effort and + * never throws: a missing or unremovable file is not an error. */ export function removeUpdateCache(): void { try { const file = getUpdateCacheFile(); - if (!existsSync(file)) return; - unlinkSync(file); + // Do not create a data directory solely for an empty tombstone. If the data + // directory already exists, always advance the generation: a refresh may be + // between writing its fully formed temp lock and atomically publishing it, + // during which neither the final lock nor cache exists yet. + if (!existsSync(getDataDir())) return; + if (!advanceDeletionGeneration()) return; + removeCacheFileOnly(); + + // Cache replacement temps are never synchronization points. Removing one + // that belongs to an in-flight writer makes its atomic rename fail closed. + const prefix = `${basename(file)}.tmp-`; + try { + for (const name of readdirSync(getDataDir())) { + if (!name.startsWith(prefix)) continue; + const candidate = join(getDataDir(), name); + const stat = lstatSync(candidate); + if (stat.isFile() && !stat.isSymbolicLink()) unlinkSync(candidate); + } + } catch { + // Best-effort cleanup; the generation still prevents resurrection. + } logger.debug("Removed update-check cache."); } catch { // Best-effort: leaving the file behind is not a failure worth surfacing. @@ -675,6 +982,26 @@ function newerTagVersion( return isNewer(parsed, current) ? parsed.raw : undefined; } +/** + * Pick a newer stable target from npm's `latest` tag. + * + * npm permits any SemVer string in a dist-tag, including prereleases. The name + * `latest` alone is therefore not evidence of GA: only a value with no + * prerelease identifiers is exposed as the stable target. + */ +function newerStableVersion( + tags: Record, + current: SemVer, +): string | undefined { + const raw = Object.prototype.hasOwnProperty.call(tags, "latest") + ? tags["latest"] + : undefined; + if (raw === undefined) return undefined; + const parsed = parseSemver(raw); + if (!parsed || parsed.prerelease.length > 0) return undefined; + return isNewer(parsed, current) ? parsed.raw : undefined; +} + /** * Reconstruct the version a cached result would have pointed at, using exactly * the same channel-first rule as a live check. Purely local: this reads the @@ -685,13 +1012,45 @@ function cachedTargetVersion(cache: UpdateCache): string | undefined { const current = parseSemver(PACKAGE_VERSION); if (current) { const channel = releaseChannel(current); - const target = - newerTagVersion(tags, channel, current) ?? newerTagVersion(tags, "latest", current); + const target = newerTagVersion(tags, channel, current) ?? newerStableVersion(tags, current); if (target) return target; } - // Nothing newer is known: still report the newest version we saw, so the - // status table can show what the cache actually holds. - return cache.channelVersion ?? cache.latest; + // Nothing newer on the running channel or on a non-prerelease `latest` tag is + // a target for this build. Do not surface an arbitrary cached prerelease as a + // stable fallback. + return undefined; +} + +/** + * Build the public update model from independently selected channel/stable + * targets. The channel wins when both are present; stable is then carried as a + * separate target rather than inferred from the `latest` tag name. + */ +function buildUpdateAvailable( + channel: string | null, + channelTarget: Extract | undefined, + stableTarget: Extract | undefined, +): UpdateAvailable | null { + const primary = channelTarget ?? stableTarget; + if (!primary) return null; + + return { + package: PACKAGE_NAME, + current: PACKAGE_VERSION, + latest: primary.version, + target: primary.kind, + channel, + ...(channelTarget && stableTarget && stableTarget.version !== primary.version + ? { + stable: stableTarget.version, + stablePackageSpec: `${PACKAGE_NAME}@latest`, + } + : {}), + packageSpec: + primary.kind === "channel" + ? `${PACKAGE_NAME}@${primary.channel}` + : `${PACKAGE_NAME}@latest`, + }; } /** @@ -712,7 +1071,7 @@ function cachedTargetVersion(cache: UpdateCache): string | undefined { function renderNotice(update: UpdateAvailable): string { const lines = [ `Update available: ${update.package} ${update.current} -> ${update.latest}` + - `${update.channel ? ` (${update.channel} channel)` : ""}.`, + `${update.target === "channel" && update.channel ? ` (${update.channel} channel)` : ""}.`, `This notice is informational only — nothing is installed or changed ` + `automatically, and no command should be run in response to it. Updating ` + `requires a person to change the MCP client configuration or the installed ` + @@ -791,7 +1150,40 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { const now = Date.now(); const cached = readCache(); - const fresh = cached && isCacheFresh(cached, registry, now) ? cached : null; + let fresh = cached && isCacheFresh(cached, registry, now) ? cached : null; + let refreshLock: string | null = null; + let deletionGeneration: string | null = null; + + if (!fresh) { + refreshLock = acquireRefreshLock(now); + if (refreshLock) { + // Another process may have completed between our first cache read and + // lock acquisition. Re-check under the lock before reserving a request. + const afterLock = readCache(); + fresh = afterLock && isCacheFresh(afterLock, registry, now) ? afterLock : null; + } else { + // The lock owner may have completed between contention and this read. + // If not, fail closed rather than issuing a concurrent request. + const afterContention = readCache(); + fresh = + afterContention && isCacheFresh(afterContention, registry, now) + ? afterContention + : null; + if (!fresh) { + status = { + enabled: true, + state: "unavailable", + currentVersion: PACKAGE_VERSION, + }; + return; + } + } + } + if (fresh && refreshLock) { + releaseRefreshLock(refreshLock); + refreshLock = null; + } + if (!fresh) deletionGeneration = readDeletionGeneration(); let tags: Record | null; let checkedAt: number; @@ -817,49 +1209,85 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { } else { notifiedFor = cached ? [...cached.notifiedFor] : []; checkedAt = now; - tags = await fetchDistTags(url, registry); - if (tags === null) { - writeCache({ + + try { + // Reserve this 24-hour attempt BEFORE egress. If the process exits after + // the request starts but before it can record the response, the failure + // reservation still prevents another process from issuing a duplicate. + const reserved = writeCacheForGeneration({ version: 1, checkedAt: now, currentVersion: PACKAGE_VERSION, registry, outcome: "failure", notifiedFor, - }); - status = { - enabled: true, - state: "unavailable", + }, deletionGeneration); + if (!reserved) { + status = { + enabled: true, + state: "unavailable", + currentVersion: PACKAGE_VERSION, + }; + return; + } + + tags = await fetchDistTags(url, registry); + if (tags === null) { + status = { + enabled: true, + state: "unavailable", + currentVersion: PACKAGE_VERSION, + lastCheckedAt: new Date(now).toISOString(), + }; + return; + } + + const channel = releaseChannel(current); + const persisted = writeCacheForGeneration({ + version: 1, + checkedAt, currentVersion: PACKAGE_VERSION, - lastCheckedAt: new Date(now).toISOString(), - }; - return; + registry, + outcome: "success", + latest: tags["latest"], + channelTag: channel ?? undefined, + channelVersion: channel ? tags[channel] : undefined, + notifiedFor, + }, deletionGeneration); + if (!persisted) { + status = { + enabled: true, + state: "unavailable", + currentVersion: PACKAGE_VERSION, + lastCheckedAt: new Date(now).toISOString(), + }; + return; + } + } finally { + if (refreshLock) releaseRefreshLock(refreshLock); } } const channel = releaseChannel(current); const channelVersion = newerTagVersion(tags ?? {}, channel, current); - const stableVersion = newerTagVersion(tags ?? {}, "latest", current); + const stableVersion = newerStableVersion(tags ?? {}, current); + const channelTarget = + channel && channelVersion + ? ({ kind: "channel", channel, version: channelVersion } as const) + : undefined; + const stableTarget = stableVersion + ? ({ kind: "stable", version: stableVersion } as const) + : undefined; + const targets: NoticeTarget[] = [ + ...(channelTarget ? [channelTarget] : []), + ...(stableTarget ? [stableTarget] : []), + ]; // Prefer the user's own channel; fall back to stable (the only target when // the running build is itself a stable release). - const latest = channelVersion ?? stableVersion; + const latest = channelTarget?.version ?? stableTarget?.version; const lastCheckedAt = new Date(checkedAt).toISOString(); - if (!fresh) { - writeCache({ - version: 1, - checkedAt, - currentVersion: PACKAGE_VERSION, - registry, - outcome: "success", - latest: tags?.["latest"], - channelTag: channel ?? undefined, - channelVersion: channel ? tags?.[channel] : undefined, - notifiedFor, - }); - } - if (!latest) { status = { enabled: true, @@ -871,17 +1299,8 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { return; } - const update: UpdateAvailable = { - package: PACKAGE_NAME, - current: PACKAGE_VERSION, - latest, - channel, - // Only call out stable separately when it is a different, additional target. - ...(stableVersion && stableVersion !== latest - ? { stable: stableVersion, stablePackageSpec: `${PACKAGE_NAME}@latest` } - : {}), - packageSpec: `${PACKAGE_NAME}@${channelVersion && channel ? channel : "latest"}`, - }; + const update = buildUpdateAvailable(channel, channelTarget, stableTarget); + if (!update) return; status = { enabled: true, @@ -898,9 +1317,31 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { // `takePendingUpdateNotice()` at *delivery* time, not here: a process that // probes and then exits before any tool call would otherwise burn the only // announcement without the user ever seeing it. - if (notifiedFor.includes(latest)) return; + const unnotifiedTargets = targets.filter( + (target) => !isTargetNotified(notifiedFor, target), + ); + if (unnotifiedTargets.length === 0) return; - pendingNotice = { text: renderNotice(update), updateAvailable: update }; + const noticeChannelTarget = unnotifiedTargets.find( + (target): target is Extract => + target.kind === "channel", + ); + const noticeStableTarget = unnotifiedTargets.find( + (target): target is Extract => + target.kind === "stable", + ); + const noticeUpdate = buildUpdateAvailable( + channel, + noticeChannelTarget, + noticeStableTarget, + ); + if (!noticeUpdate) return; + + pendingNotice = { + text: renderNotice(noticeUpdate), + updateAvailable: noticeUpdate, + suppressionKeys: unnotifiedTargets.map(targetSuppressionKey), + }; logger.debug(`Update available: ${PACKAGE_VERSION} -> ${latest}`); } catch { // A best-effort courtesy must never affect the server. @@ -947,32 +1388,65 @@ export function startUpdateCheck(options: StartUpdateCheckOptions = {}): void { * Cross-process suppression is persisted *here*, at delivery, rather than when * the probe found the update: a process that exits before any tool call leaves * the cache untouched, so the next process still announces the version. The - * cache is re-read immediately before writing so a concurrent process's entries - * are merged rather than clobbered. Known residual risk: two processes that - * deliver at the same instant can still interleave (last writer wins) and one - * suppression entry may be lost, costing at most one extra notice; fixing that - * needs compare-and-swap support in `secure-fs`, tracked as follow-up work. + * cache is re-read immediately before writing under the same cross-process lock + * as refreshes, so concurrent entries are merged rather than clobbered. * * An absent cache file is never re-created here: `spe-mcp logout` deletes it, * and delivery must not resurrect state the user just erased. */ export function takePendingUpdateNotice(): UpdateNotice | null { - const notice = pendingNotice; + const pending = pendingNotice; pendingNotice = null; - if (notice) persistNotified(notice.updateAvailable.latest); - return notice; + if (!pending) return null; + persistNotified(pending.suppressionKeys); + return { text: pending.text, updateAvailable: pending.updateAvailable }; } -/** Record `version` as announced, merging into whatever is on disk right now. */ -function persistNotified(version: string): void { +/** Namespaced suppression key so channel and stable targets advance independently. */ +function targetSuppressionKey(target: NoticeTarget): string { + return target.kind === "stable" + ? `stable:${target.version}` + : `channel:${target.channel}:${target.version}`; +} + +/** + * Backward-compatible suppression check. + * + * Version-only entries came from cache version 1 before target namespacing. A + * matching historical version still counts as delivered; new writes always use + * namespaced keys so a channel update cannot suppress a later stable target. + */ +function isTargetNotified(notifiedFor: readonly string[], target: NoticeTarget): boolean { + return ( + notifiedFor.includes(targetSuppressionKey(target)) || + notifiedFor.includes(target.version) + ); +} + +/** Record target keys as announced, serialized with refresh cache writes. */ +function persistNotified(suppressionKeys: readonly string[]): void { + let lock: string | null = null; try { + // No cache (never written, or deleted by logout) => do not create a lock or + // resurrect state the user intentionally removed. + if (!readCache()) return; + lock = acquireRefreshLock(Date.now()); + if (!lock) return; + const deletionGeneration = readDeletionGeneration(); + const cache = readCache(); // No cache (never written, or deleted by logout) => nothing to update. if (!cache) return; - if (cache.notifiedFor.includes(version)) return; - writeCache({ ...cache, notifiedFor: [...cache.notifiedFor, version] }); + const missing = suppressionKeys.filter((key) => !cache.notifiedFor.includes(key)); + if (missing.length === 0) return; + writeCacheForGeneration( + { ...cache, notifiedFor: [...cache.notifiedFor, ...missing] }, + deletionGeneration, + ); } catch { // Best-effort: at worst the notice is shown once more next run. + } finally { + if (lock) releaseRefreshLock(lock); } } @@ -1038,7 +1512,18 @@ export const __testing = { readCappedText, readCache, writeCache, + getRefreshLockFile, + getRefreshRecoveryLockFile, + getDeletionGenerationFile, + acquireRefreshLock, + releaseRefreshLock, isCacheFresh, + newerStableVersion, + targetSuppressionKey, + isTargetNotified, + lockOwner, + lockOwnerMayBeAlive, + cleanupAbandonedUpdateTemps, renderNotice, removeUpdateCache, envFlagDisabled, diff --git a/src/update-notice-e2e.test.ts b/src/update-notice-e2e.test.ts index 0e78c3a..6d2835c 100644 --- a/src/update-notice-e2e.test.ts +++ b/src/update-notice-e2e.test.ts @@ -75,6 +75,12 @@ const SEED_LATEST = "999.0.0"; const SEED_CHANNEL_VERSION = CHANNEL_TAG ? `999.0.0-${CHANNEL_TAG}.1` : undefined; /** Channel-first, exactly as a live check would resolve it. */ const EXPECTED_LATEST = SEED_CHANNEL_VERSION ?? SEED_LATEST; +const EXPECTED_NOTIFICATION_KEYS = [ + ...(CHANNEL_TAG && SEED_CHANNEL_VERSION + ? [`channel:${CHANNEL_TAG}:${SEED_CHANNEL_VERSION}`] + : []), + `stable:${SEED_LATEST}`, +]; let isolatedHome = ""; let dataDir = ""; @@ -231,6 +237,7 @@ describe("update notice delivery over spawned JSON-RPC (AB#3219517)", () => { expect(update, "structuredContent.updateAvailable should be created").toBeDefined(); expect(update?.["latest"]).toBe(EXPECTED_LATEST); expect(update?.["current"]).toBe(pkg.version); + expect(update?.["target"]).toBe(CHANNEL_TAG ? "channel" : "stable"); const second = await callSafeTool(client); expect(second.text, "the notice must not repeat within a session").not.toMatch( @@ -245,7 +252,7 @@ describe("update notice delivery over spawned JSON-RPC (AB#3219517)", () => { expect( cache?.notifiedFor, "delivery should persist the announced target for future processes", - ).toEqual([EXPECTED_LATEST]); + ).toEqual(EXPECTED_NOTIFICATION_KEYS); }, 60000); // (3) Cross-process suppression: once delivered, never again for that target. @@ -261,8 +268,8 @@ describe("update notice delivery over spawned JSON-RPC (AB#3219517)", () => { await stopServer(client, transport); } - expect(readCacheFile()?.notifiedFor, "suppression list should not grow").toEqual([ - EXPECTED_LATEST, - ]); + expect(readCacheFile()?.notifiedFor, "suppression list should not grow").toEqual( + EXPECTED_NOTIFICATION_KEYS, + ); }, 60000); }); From 2e30219fb093d6b41db55ab8bb3587db449db6aa Mon Sep 17 00:00:00 2001 From: audit Date: Wed, 26 Aug 2026 13:07:12 -0700 Subject: [PATCH 9/9] fix(update-check): harden cross-process notice coordination Use unique bakery-lock contenders and durable under-lock notice claims. Coalesce equal stable/channel targets, document at-most-once and runtime-dependent proxy behavior, and add real multi-process race coverage. AB#3219463 Copilot-Session-Id: copilotcli:/9b07fed7-d2cf-4209-8682-6c3004401c04 --- CHANGELOG.md | 7 +- PRIVACY.md | 26 +- README.md | 21 +- docs/DATA-FLOW.md | 33 ++- docs/SECURITY-CONTROLS.md | 2 +- docs/TROUBLESHOOTING.md | 11 +- src/update-check.test.ts | 200 ++++++++++++-- src/update-check.ts | 486 +++++++++++++++++++++++----------- src/update-notice-e2e.test.ts | 155 ++++++++++- 9 files changed, 711 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5468c49..d9fd6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,9 +78,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). owner-only (SEC-003) with a 24-hour TTL — a failed check backs off for the same 24 hours, so at most one request per day is made either way — deleted on `logout` / `auth --reset`. `SPE_NPM_REGISTRY` values carrying credentials, a query string, or a - fragment are rejected. **Known limitation:** Node's built-in `fetch` ignores - `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it - fails closed. + fragment are rejected. Proxy routing follows the runtime configuration: releases that support + Node's environment-proxy mode (including current Node 24/26 releases) can use + `HTTP(S)_PROXY`/`NO_PROXY` when enabled with `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`; + Node 22 may ignore those variables and attempt a direct connection. - **Fail-closed credential/state file handling.** The data directory and token cache files are now validated fail-closed: a symlinked, foreign-owned, or diff --git a/PRIVACY.md b/PRIVACY.md index 9c8c94e..1fa8d96 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -104,10 +104,11 @@ newer releases, which can also be turned off. Specifically: identifier**. It is **retained locally until you delete it**: there is no automatic expiry of the file itself, only of its freshness. Run `spe-mcp logout` or `spe-mcp auth --reset` to delete it, or remove the file by hand. A transient owner-only - `update-check.json.lock` file (plus a recovery lock only while reclaiming an abandoned lock) - coordinates processes that share the data directory. Each contains only the local process ID - and lock-acquisition timestamp, is removed after the operation, and an abandoned lock can be - reclaimed after 30 seconds only after the recorded process is no longer alive. The + uniquely named `update-check.json.lock-*` files coordinate processes that share the data + directory. Each contains only the local process ID, lock-acquisition timestamp, a random + contender name, and a local ordering number; it is removed after the operation, and an + abandoned contender can be reclaimed after 30 seconds only after the recorded process is no + longer alive. Names are never reused, so stale cleanup cannot delete a successor lock. The refresh-lock owner writes the 24-hour attempt timestamp to the cache before opening the registry connection, so another process cannot start a duplicate request if the first process exits after egress. Changing the running package version or registry, or @@ -118,6 +119,11 @@ newer releases, which can also be turned off. Specifically: owner-only `update-check.json.tmp-*` file after an abrupt exit; it is cleaned by the next eligible check after 30 seconds or by logout/reset. + A process claims a pending update target in this cache before returning its notice, preventing + duplicate delivery by processes that share the cache. This is at-most-once delivery: a crash + after the durable claim but before the client receives the response can lose the notice rather + than repeat it. The cached update remains available through `status_get`. + Logout/reset also writes an owner-only `update-check.json.deleted` generation containing only a timestamp before deleting the cached result. That local tombstone prevents a registry request already in flight from recreating the cache after deletion. It is not transmitted, @@ -134,13 +140,11 @@ newer releases, which can also be turned off. Specifically: source checkout, and can be disabled outright (see [Turning it off](#turning-it-off)); when disabled, **no request is made, no notice is printed, and no cache file is written**. - **Known limitation (proxy).** The check uses the Node.js built-in `fetch`, which does **not** - honour `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`. On a network that requires an egress proxy - the request simply fails and is silently ignored (fail-closed — no data leaves by another - route), but it also means the check **cannot be routed through your proxy for inspection or - policy enforcement**. Adding proxy support would require a new runtime dependency, which this - project deliberately avoids. This is recorded as an **open, unresolved tradeoff**; if your - environment requires all egress to be proxied, disable the check. + **Proxy routing.** Routing depends on the Node.js runtime configuration. Releases that support + Node's environment-proxy mode (including current Node 24/26 releases) can honor `HTTP_PROXY` / + `HTTPS_PROXY` / `NO_PROXY` when it is enabled with `NODE_USE_ENV_PROXY=1` or + `--use-env-proxy`; Node 22 may ignore those variables and attempt a direct connection. If + proxy routing is required, enforce it at the runtime or network layer, or disable the check. See [docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full list of network endpoints and what travels to each. diff --git a/README.md b/README.md index 7a249ee..33939b6 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,12 @@ How it behaves: - **Channel-aware.** A prerelease install (e.g. `alpha`) is compared against its own dist-tag. The `latest` target is mentioned separately as **stable** only when it resolves to a non-prerelease version. -- **Quiet.** Channel and stable targets are tracked independently, and each is - shown once per newer version rather than on every call. +- **Quiet.** Channel and stable targets are tracked independently. A target is + claimed in the shared cache before its notice is returned, so processes + sharing that cache do not return duplicate notices. This is an at-most-once + guarantee: a process crash after the durable claim but before the client + receives the tool result can lose that notice. The update remains visible in + `status_get`. - **Unauthenticated, without a user identifier.** Exactly one unauthenticated `GET` of the package metadata — by default, `https://registry.npmjs.org/@microsoft%2fspe-mcp` — with no query string and @@ -194,12 +198,13 @@ How it behaves: > policies, and compliance boundary depend on your configuration. Disable the > update check to remove registry egress entirely. -> **Known limitation.** Node's built-in `fetch` does not honour `HTTP_PROXY` / -> `HTTPS_PROXY` / `NO_PROXY`, so this request cannot be routed through an egress -> proxy for inspection. It fails closed — the check is skipped, and no data -> leaves by another route. Adding proxy support would require a new runtime -> dependency, which this project avoids; this is an open, unresolved tradeoff. -> Disable the check in environments where all egress must be proxied. +> **Proxy routing.** Routing follows the Node.js runtime configuration. Releases +> that support Node's environment-proxy mode (including current Node 24/26 +> releases) can honor `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` when it is +> enabled with `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`. Node 22 may ignore +> those variables and attempt a direct connection. If proxy routing is +> mandatory, enforce it at the runtime or network layer, or disable the update +> check. It is skipped automatically when the server is run from a source checkout or in CI, and can be turned off explicitly: diff --git a/docs/DATA-FLOW.md b/docs/DATA-FLOW.md index db56780..7b2c9d5 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -56,11 +56,12 @@ By default, two calls use public endpoints outside your tenant, and neither carr **entirely** — it does not merely drop the `User-Agent`) — in which case no request is made, no notice is printed, and no cache is written. See [PRIVACY.md](../PRIVACY.md). - - **Known limitation:** Node's built-in `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` / - `NO_PROXY`, so this request cannot be routed through an egress proxy for inspection. It - fails closed (the check is silently skipped) rather than falling back to another route. - Fixing this would require a new runtime dependency, which the project avoids; recorded as - an **open, unresolved tradeoff**. Disable the check where all egress must be proxied. + - **Proxy routing:** Routing depends on the Node.js runtime configuration. Releases that + support Node's environment-proxy mode (including current Node 24/26 releases) can honor + `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` when it is enabled with + `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`; Node 22 may ignore those variables and attempt + a direct connection. If proxy routing is required, enforce it at the runtime or network + layer, or disable the check. ## Local artifacts @@ -74,15 +75,19 @@ These never leave your machine: have already been notified about — **no identifiers of any kind**. It is **retained until you delete it**; `spe-mcp logout` and `spe-mcp auth --reset` remove it, and `status_get` prints its full path. -- The transient owner-only **update-check lock files** (`update-check.json.lock`, plus a recovery - lock only while reclaiming an abandoned lock). They contain only the local process ID and - lock-acquisition timestamp, serialize stale-cache refreshes and cache suppression writes - across processes sharing the data directory, and are removed after use (or reclaimed after 30 - seconds once the recorded process is no longer alive). Neither value is transmitted or copied - to the persistent cache. The refresh-lock owner records the 24-hour attempt in the cache - before egress. An abrupt exit while atomically publishing a lock can leave an owner-only - `.tmp-*` lock file; the next eligible check cleans it after the same liveness/30-second test, - or it remains local until the data directory is deleted. +- The transient owner-only **update-check lock files** (`update-check.json.lock-*`). Each unique + contender contains only the local process ID, lock-acquisition timestamp, a random contender + name, and a local ordering number. They serialize stale-cache refreshes and cache suppression + writes across processes sharing the data directory, and are removed after use (or reclaimed + after 30 seconds once the recorded process is no longer alive). Names are never reused, so + stale cleanup cannot delete a successor lock. None of these values is transmitted or copied + to the persistent cache. A pending notice is claimed in the cache before it is returned, + preventing duplicate delivery across processes. This is at-most-once delivery: a crash after + the durable claim but before the response reaches the client can lose the notice. The + refresh-lock owner records the 24-hour attempt in the cache before egress. An abrupt exit + while atomically publishing a lock can leave an owner-only `.tmp-*` lock file; the next + eligible check cleans it after the same liveness/30-second test, or it remains local until the + data directory is deleted. - The owner-only **update-check deletion generation** (`update-check.json.deleted`), containing only a timestamp. Logout/reset advances it before deleting the cached registry result so an in-flight refresh cannot recreate that result afterward. It is not transmitted and remains diff --git a/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md index e480b30..4c553a3 100644 --- a/docs/SECURITY-CONTROLS.md +++ b/docs/SECURITY-CONTROLS.md @@ -24,7 +24,7 @@ that maps each code to a human-readable name and a one-line description. | SEC-002 | Client-safe error messages | Tool `catch` blocks surface only sanitized, consistent messages to clients; internal detail stays in server-side logs. | | SEC-003 | Secure filesystem (owner-only) | Credential and state files (token cache, server state) are written owner-only (POSIX `0o600`; ACL-governed on Windows). | | SEC-007 | Docs endpoint validation | The Microsoft Learn MCP endpoint is resolved and validated before use to prevent redirection to an untrusted host. | -| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path per refresh (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data), discloses only what any HTTPS connection reveals (IP address, the static product `User-Agent`, and standard TLS/HTTP connection metadata such as the TLS handshake/SNI, `Host`/`Accept` headers, and request timing), is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), and cached owner-only via SEC-003 with a 24 h TTL. An owner-only cross-process refresh lock serializes stale-cache refreshes, and its owner records a failure reservation before egress so concurrent starts, failed checks, and mid-request process exits still allow at most one request per 24 h for the same running package version and registry across processes sharing the retained data-directory cache. Changing that version/registry or deleting the cache starts a new window. The cache is deleted on `logout` / `auth --reset`; channel and stable notification suppression is persisted independently. The check announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK` (legacy alias), `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false` — the telemetry opt-out suppresses the registry request entirely rather than merely omitting the `User-Agent`; auto-skipped in CI and source checkouts). **Known limitation:** Node's built-in `fetch` ignores `HTTP(S)_PROXY`/`NO_PROXY`, so the request cannot be routed through an egress proxy; it fails closed. Recorded as an open tradeoff to preserve the zero-runtime-dependency budget. | +| SEC-008 | Update-check hardening | The optional npm version check is HTTPS-only (`SPE_NPM_REGISTRY` must be `https:` with no credentials/query/fragment), requests **exactly one** fixed package path per refresh (no query, no fragment), **rejects redirects and any response served by a different host**, is unauthenticated (no `Authorization`, cookies, `credentials: "omit"`, no `.npmrc`, no `npm` subprocess), sends **no identifier** (no install GUID, machine, user, tenant, subscription, correlation, or session data), discloses only what any HTTPS connection reveals (IP address, the static product `User-Agent`, and standard TLS/HTTP connection metadata such as the TLS handshake/SNI, `Host`/`Accept` headers, and request timing), is bounded (2 s timeout, 64 KB response cap), parsed defensively (strict SemVer, prototype-pollution-safe key filtering), and cached owner-only via SEC-003 with a 24 h TTL. An owner-only cross-process refresh lock serializes stale-cache refreshes, and its owner records a failure reservation before egress so concurrent starts, failed checks, and mid-request process exits still allow at most one request per 24 h for the same running package version and registry across processes sharing the retained data-directory cache. Changing that version/registry or deleting the cache starts a new window. The cache is deleted on `logout` / `auth --reset`; channel and stable notification suppression is persisted independently and claimed before return for at-most-once cross-process delivery (a crash after claim but before response delivery can lose the notice). The check announces itself with a one-time stderr collection notice before the first request, is **notify-only** (never downloads, installs, or executes anything), and is fully disableable with **zero network and zero disk access** (`SPE_MCP_UPDATE_CHECK=false`, `--no-update-check`, `SPE_NO_UPDATE_CHECK` (legacy alias), `NO_UPDATE_NOTIFIER`, `SPE_MCP_COLLECT_TELEMETRY=false` — the telemetry opt-out suppresses the registry request entirely rather than merely omitting the `User-Agent`; auto-skipped in CI and source checkouts). Proxy routing follows the runtime configuration: releases that support Node's environment-proxy mode (including current Node 24/26 releases) can use `HTTP(S)_PROXY`/`NO_PROXY` when enabled with `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, while Node 22 may ignore those variables and attempt a direct connection. | > Adding a new safeguard? Give it the next code in its family and add a row here > so code comments and tests have a lookup. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4090197..a7627b2 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -112,11 +112,12 @@ Common situations: silently; the failure is cached so the server does not retry on every call. `status_get` reports `— unavailable (registry not reachable)`. This is harmless — no functionality depends on it. -- **The check never succeeds behind an egress proxy.** Node's built-in `fetch` does **not** - honour `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, so the request cannot be routed through a - proxy. It fails closed — nothing leaves by another route. Adding proxy support would require - a new runtime dependency, which this project avoids; this is an open, unresolved tradeoff. - In proxy-only environments, disable the check with `SPE_MCP_UPDATE_CHECK=false`. +- **Proxy-only environment.** Routing depends on the Node.js runtime configuration. Releases + that support Node's environment-proxy mode (including current Node 24/26 releases) can honor + `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` when it is enabled with + `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`; Node 22 may ignore those variables and attempt a + direct connection. Enforce mandatory proxy routing at the runtime or network layer, or + disable the check with `SPE_MCP_UPDATE_CHECK=false`. - **Internal/mirror registry.** Set `SPE_NPM_REGISTRY` to your mirror. It must be an `https:` URL with no embedded credentials, query string, or fragment; anything else is ignored and the check is disabled for that run. Redirects and cross-host responses are rejected. The diff --git a/src/update-check.test.ts b/src/update-check.test.ts index 46d3959..f8cdd8a 100644 --- a/src/update-check.test.ts +++ b/src/update-check.test.ts @@ -439,6 +439,14 @@ describe("cache", () => { expect(__testing.readCache()?.notifiedFor).toEqual(["1.0.0"]); }); + it("retains the complete suppression history for the cache lifetime", () => { + const notifiedFor = Array.from({ length: 20 }, (_, index) => `stable:${index + 1}.0.0`); + expect(__testing.writeCache({ ...base, notifiedFor })).toBe(true); + + expect(__testing.readCache()?.notifiedFor).toEqual(notifiedFor); + expect(readCacheFile()["notifiedFor"]).toEqual(notifiedFor); + }); + it("treats a recent success as fresh", () => { expect(__testing.isCacheFresh(base, DEFAULT_REGISTRY, base.checkedAt + 1_000)).toBe(true); }); @@ -687,6 +695,34 @@ describe("runUpdateCheck", () => { }, ); + it.runIf(CHANNEL === "alpha")( + "coalesces equal alpha and latest GA targets in favor of stable", + async () => { + const ga = `${CURRENT.major + 1}.0.0`; + respondWith(packument({ alpha: ga, latest: ga })); + + await __testing.runUpdateCheck({}); + + expect(getUpdateStatus().updateAvailable).toMatchObject({ + latest: ga, + target: "stable", + channel: "alpha", + packageSpec: `${PACKAGE_NAME}@latest`, + }); + expect(getUpdateStatus().updateAvailable?.stable).toBeUndefined(); + + const notice = takePendingUpdateNotice(); + expect(notice?.updateAvailable).toMatchObject({ + latest: ga, + target: "stable", + packageSpec: `${PACKAGE_NAME}@latest`, + }); + expect(notice?.text).not.toContain("(alpha channel)"); + expect(notice?.text).toContain(`${PACKAGE_NAME}@latest`); + expect(readCacheFile()["notifiedFor"]).toEqual([`stable:${ga}`]); + }, + ); + it("never labels a prerelease value from the latest tag as stable", async () => { const prereleaseLatest = `${CURRENT.major + 2}.0.0-rc.1`; respondWith( @@ -815,11 +851,22 @@ describe("runUpdateCheck", () => { finishFetch?.(new Response(packument(tagsFixture()), { status: 200 })); await first; expect(readCacheFile()["outcome"]).toBe("success"); - expect(existsSync(__testing.getRefreshLockFile())).toBe(false); + expect(__testing.listRefreshLocks()).toEqual([]); }); it("fails closed without egress while another process holds an active refresh lock", async () => { - writeFileSync(__testing.getRefreshLockFile(), "other-process", "utf8"); + const lock = `${__testing.getRefreshLockPrefix()}active`; + writeFileSync( + lock, + JSON.stringify({ + pid: process.pid, + createdAt: Date.now(), + id: "active", + state: "ready", + ticket: 1, + }), + "utf8", + ); respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); @@ -827,15 +874,25 @@ describe("runUpdateCheck", () => { expect(fetchMock).not.toHaveBeenCalled(); expect(cacheExists()).toBe(false); expect(getUpdateStatus().state).toBe("unavailable"); - expect(readFileSync(__testing.getRefreshLockFile(), "utf8")).toBe("other-process"); + expect(existsSync(lock)).toBe(true); }); it("reclaims an abandoned stale refresh lock and completes one request", async () => { - const lock = __testing.getRefreshLockFile(); + const lock = `${__testing.getRefreshLockPrefix()}stale`; const createdAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; const deadPid = 999_999_991; mockExitedProcess(deadPid); - writeFileSync(lock, JSON.stringify({ pid: deadPid, createdAt }), "utf8"); + writeFileSync( + lock, + JSON.stringify({ + pid: deadPid, + createdAt, + id: "stale", + state: "ready", + ticket: 1, + }), + "utf8", + ); const staleTime = new Date(createdAt); utimesSync(lock, staleTime, staleTime); respondWith(packument(tagsFixture())); @@ -848,7 +905,7 @@ describe("runUpdateCheck", () => { }); it("fails closed on stale malformed lock metadata", async () => { - const lock = __testing.getRefreshLockFile(); + const lock = `${__testing.getRefreshLockPrefix()}malformed`; writeFileSync(lock, "partially-written", "utf8"); const staleTime = new Date(Date.now() - REFRESH_LOCK_STALE_MS - 1_000); utimesSync(lock, staleTime, staleTime); @@ -862,12 +919,15 @@ describe("runUpdateCheck", () => { }); it("never replaces an old lock while its recorded process is still alive", async () => { - const lock = __testing.getRefreshLockFile(); + const lock = `${__testing.getRefreshLockPrefix()}alive`; writeFileSync( lock, JSON.stringify({ pid: process.pid, createdAt: Date.now() - REFRESH_LOCK_STALE_MS - 1_000, + id: "alive", + state: "ready", + ticket: 1, }), "utf8", ); @@ -883,50 +943,71 @@ describe("runUpdateCheck", () => { }); it("does not remove or bypass a non-file refresh lock entry", async () => { - mkdirSync(__testing.getRefreshLockFile()); + const lock = `${__testing.getRefreshLockPrefix()}directory`; + mkdirSync(lock); respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); expect(fetchMock).not.toHaveBeenCalled(); expect(getUpdateStatus().state).toBe("unavailable"); - expect(existsSync(__testing.getRefreshLockFile())).toBe(true); + expect(existsSync(lock)).toBe(true); }); - it("does not acquire or egress while stale-lock recovery is active", async () => { - writeFileSync(__testing.getRefreshRecoveryLockFile(), "other-reclaimer", "utf8"); + it("does not acquire or egress while another contender is choosing", async () => { + const choosing = `${__testing.getRefreshLockPrefix()}choosing`; + writeFileSync( + choosing, + JSON.stringify({ + pid: process.pid, + createdAt: Date.now(), + id: "choosing", + state: "choosing", + }), + "utf8", + ); respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); expect(fetchMock).not.toHaveBeenCalled(); expect(getUpdateStatus().state).toBe("unavailable"); - expect(existsSync(__testing.getRefreshLockFile())).toBe(false); + expect(existsSync(choosing)).toBe(true); }); - it("reclaims an abandoned recovery lock before acquiring the refresh lock", () => { - const recovery = __testing.getRefreshRecoveryLockFile(); + it("reclaims an abandoned unique contender before acquiring the refresh lock", () => { + const abandoned = `${__testing.getRefreshLockPrefix()}abandoned`; const createdAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; const deadPid = 999_999_992; mockExitedProcess(deadPid); - writeFileSync(recovery, JSON.stringify({ pid: deadPid, createdAt }), "utf8"); + writeFileSync( + abandoned, + JSON.stringify({ + pid: deadPid, + createdAt, + id: "abandoned", + state: "ready", + ticket: 1, + }), + "utf8", + ); const staleTime = new Date(createdAt); - utimesSync(recovery, staleTime, staleTime); + utimesSync(abandoned, staleTime, staleTime); const token = __testing.acquireRefreshLock(Date.now()); expect(token).not.toBeNull(); - expect(existsSync(recovery)).toBe(false); - expect(existsSync(__testing.getRefreshLockFile())).toBe(true); + expect(existsSync(abandoned)).toBe(false); + expect(__testing.listRefreshLocks()).toEqual([token]); __testing.releaseRefreshLock(token as string); - expect(existsSync(__testing.getRefreshLockFile())).toBe(false); + expect(__testing.listRefreshLocks()).toEqual([]); }); it("cleans an abandoned atomic-publication temp lock on the next acquisition", () => { const createdAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; const deadPid = 999_999_993; mockExitedProcess(deadPid); - const tempLock = `${__testing.getRefreshLockFile()}.tmp-abandoned`; + const tempLock = `${__testing.getRefreshLockPrefix()}abandoned.tmp-fixture`; writeFileSync(tempLock, JSON.stringify({ pid: deadPid, createdAt }), "utf8"); const staleTime = new Date(createdAt); utimesSync(tempLock, staleTime, staleTime); @@ -1514,7 +1595,7 @@ describe("privacy: cache retention and deletion", () => { // Deleting the cached registry result is a privacy promise. A small local // deletion-generation tombstone may remain to prevent in-flight writers from // quietly recreating the result the user asked to remove. - it("does not recreate the cache when a pending notice is delivered after deletion", async () => { + it("does not return or recreate a pending notice after cache deletion", async () => { respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); expect(cacheExists()).toBe(true); @@ -1523,8 +1604,8 @@ describe("privacy: cache retention and deletion", () => { expect(cacheExists()).toBe(false); const notice = takePendingUpdateNotice(); - expect(notice, "a notice was pending before the deletion").not.toBeNull(); - expect(cacheExists(), "delivery must not resurrect a deleted cache").toBe(false); + expect(notice, "an unclaimable notice must be dropped").toBeNull(); + expect(cacheExists(), "claiming must not resurrect a deleted cache").toBe(false); }); it("does not recreate the cache when logout races an in-flight registry request", async () => { @@ -1552,6 +1633,57 @@ describe("privacy: cache retention and deletion", () => { expect(getUpdateStatus().state).toBe("unavailable"); }); + it("does not adopt a logout generation that advances before lock acquisition", async () => { + respondWith(packument({ latest: PACKAGE_VERSION })); + await __testing.runUpdateCheck({}); + const stale = readCacheFile(); + writeFileSync( + getUpdateCacheFile(), + JSON.stringify({ ...stale, checkedAt: Date.now() - CHECK_TTL_MS }), + "utf8", + ); + __testing.reset(); + __testing.setInstalled(true); + fetchMock.mockClear(); + + // Force lock acquisition to observe a proven-dead contender. The liveness + // probe runs after runUpdateCheck's initial cache read but before it owns + // the refresh lock, giving logout a deterministic point to advance the + // deletion generation and remove the cache. + const deadPid = 999_999_990; + const lock = `${__testing.getRefreshLockPrefix()}logout-race`; + const createdAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; + writeFileSync( + lock, + JSON.stringify({ + pid: deadPid, + createdAt, + id: "logout-race", + state: "ready", + ticket: 1, + }), + "utf8", + ); + const staleTime = new Date(createdAt); + utimesSync(lock, staleTime, staleTime); + vi.spyOn(process, "kill").mockImplementation( + ((candidate: number) => { + if (candidate === deadPid) { + removeUpdateCache(); + throw Object.assign(new Error("no such process"), { code: "ESRCH" }); + } + return true; + }) as typeof process.kill, + ); + + await __testing.runUpdateCheck({}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(cacheExists(), "pre-lock logout must remain authoritative").toBe(false); + expect(takePendingUpdateNotice()).toBeNull(); + expect(getUpdateStatus().state).toBe("unavailable"); + }); + // The CLI is where the deletion is actually triggered. Spawning `logout` would // touch real credential state, so assert the wiring statically instead: both // credential-clearing paths must call the cache removal. @@ -1684,18 +1816,34 @@ describe("notice delivery is what marks a version as notified", () => { expect(readCacheFile()["notifiedFor"]).toEqual(["7.7.7", ...EXPECTED_NOTIFICATION_KEYS]); }); - it("does not recreate a cache that logout deleted", async () => { + it("does not return or recreate a notice that logout made unclaimable", async () => { respondWith(packument(tagsFixture())); await __testing.runUpdateCheck({}); removeUpdateCache(); expect(cacheExists()).toBe(false); - // The pending notice is still delivered, but no file comes back. - expect(takePendingUpdateNotice()).not.toBeNull(); + // Returning it without a durable claim could duplicate it in another + // process. Logout wins, so the pending notice is dropped. + expect(takePendingUpdateNotice()).toBeNull(); expect(cacheExists()).toBe(false); }); + it("retries a pending notice after a transient cache-read failure", async () => { + respondWith(packument(tagsFixture())); + await __testing.runUpdateCheck({}); + const saved = readCacheFile(); + + rmSync(getUpdateCacheFile(), { force: true }); + mkdirSync(getUpdateCacheFile()); + expect(takePendingUpdateNotice()).toBeNull(); + + rmSync(getUpdateCacheFile(), { recursive: true, force: true }); + expect(__testing.writeCache(saved as Parameters[0])).toBe(true); + expect(takePendingUpdateNotice()).not.toBeNull(); + expect(readCacheFile()["notifiedFor"]).toEqual(EXPECTED_NOTIFICATION_KEYS); + }); + it("never writes on delivery when there is nothing to deliver", async () => { respondWith(packument({ latest: PACKAGE_VERSION, ...(CHANNEL ? { [CHANNEL]: PACKAGE_VERSION } : {}) })); await __testing.runUpdateCheck({}); diff --git a/src/update-check.ts b/src/update-check.ts index b857cd3..7746781 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -15,10 +15,11 @@ * - When the installed build is a prerelease (e.g. `0.2.0-alpha.1`) the matching * channel dist-tag (`alpha`) is the primary target, and a newer STABLE release * is reported separately so prerelease users learn when GA lands. - * - The result is surfaced by appending one short notice to exactly ONE - * subsequent successful tool result (plus `structuredContent.updateAvailable`), - * and by `status_get`. It is never printed to stdout — stdout is the JSON-RPC - * channel and writing to it corrupts the protocol. + * - The result is surfaced by appending one short notice to at most one + * subsequent successful tool result across processes that share the data + * directory (plus `structuredContent.updateAvailable`), and by `status_get`. + * It is never printed to stdout — stdout is the JSON-RPC channel and writing + * to it corrupts the protocol. * * WHERE THE DATA GOES (disclosure) * - The only endpoint contacted is the public npm registry, by default @@ -74,12 +75,14 @@ * egress, lock errors, and stale-lock recovery therefore cannot cause a * second request inside the reservation window while the cache is retained. * - * KNOWN LIMITATION (accepted tradeoff, not a sign-off) - * - Node's built-in `fetch` does not honour `HTTP_PROXY` / `HTTPS_PROXY` / - * `NO_PROXY`. Adding proxy support would require a new runtime dependency, - * which this package deliberately does not take. On a proxy-only network the - * probe simply fails closed (silent no-op) rather than bypassing the proxy. - * Operators who must not egress at all should turn the check off outright. + * PROXY ROUTING + * - Routing follows the running Node.js configuration. Releases that support + * Node's environment-proxy mode (including current Node 24/26 releases) can + * use `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` when it is enabled with + * `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`; Node 22 may ignore those + * variables and attempt a direct connection. Operators who require enforced + * proxy routing must configure it at the runtime/network layer or disable + * this check outright. * * ZERO-NETWORK OPT-OUTS — each skips the check entirely (no request, no notice, * no cache read, no cache write): `--no-update-check`, `SPE_MCP_UPDATE_CHECK=false`, @@ -87,7 +90,13 @@ * `SPE_MCP_COLLECT_TELEMETRY=false`, any CI marker, and source checkouts. */ -import { existsSync, lstatSync, readdirSync, unlinkSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { + existsSync, + lstatSync, + readdirSync, + unlinkSync, +} from "node:fs"; import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -134,9 +143,6 @@ export const FAILURE_BACKOFF_MS = CHECK_TTL_MS; */ export const REFRESH_LOCK_STALE_MS = 30_000; -/** Cap on remembered "already told the user about this version" entries. */ -const MAX_NOTIFIED_ENTRIES = 10; - /** Cap on a dist-tag name we are willing to look at. */ const MAX_TAG_NAME_LENGTH = 64; @@ -303,8 +309,9 @@ type NoticeTarget = | { readonly kind: "channel"; readonly channel: string; readonly version: string } | { readonly kind: "stable"; readonly version: string }; -interface PendingUpdateNotice extends UpdateNotice { - readonly suppressionKeys: readonly string[]; +interface PendingUpdateNotice { + readonly channel: string | null; + readonly targets: readonly NoticeTarget[]; } // --------------------------------------------------------------------------- @@ -600,16 +607,8 @@ async function fetchDistTags( // Cache // --------------------------------------------------------------------------- -/** Read the cache, tolerating absence, corruption, and secure-fs rejections. */ -function readCache(): UpdateCache | null { - let raw: string | null; - try { - raw = readSecureFile(getUpdateCacheFile()); - } catch { - return null; - } - if (raw === null) return null; - +/** Parse the owner-only cache after secure-fs has read it. */ +function parseCache(raw: string): UpdateCache | null { try { const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; @@ -636,20 +635,45 @@ function readCache(): UpdateCache | null { channelTag: isSafeTagName(candidate.channelTag) ? candidate.channelTag : undefined, channelVersion: typeof candidate.channelVersion === "string" ? candidate.channelVersion : undefined, - notifiedFor: notified.slice(-MAX_NOTIFIED_ENTRIES), + // Never evict a delivered target while this cache is retained. Otherwise + // a dist-tag rollback could make an old target appear unannounced again. + notifiedFor: [...new Set(notified)], }; } catch { return null; } } +type CacheReadResult = + | { readonly state: "value"; readonly cache: UpdateCache } + | { readonly state: "missing" | "invalid" | "unreadable" }; + +/** Read the cache while preserving the distinction needed by notice claims. */ +function readCacheResult(): CacheReadResult { + let raw: string | null; + try { + raw = readSecureFile(getUpdateCacheFile()); + } catch { + return { state: "unreadable" }; + } + if (raw === null) return { state: "missing" }; + const cache = parseCache(raw); + return cache ? { state: "value", cache } : { state: "invalid" }; +} + +/** Read the cache, tolerating absence, corruption, and secure-fs rejections. */ +function readCache(): UpdateCache | null { + const result = readCacheResult(); + return result.state === "value" ? result.cache : null; +} + /** Persist the cache with owner-only permissions. Returns false on any refusal. */ function writeCache(cache: UpdateCache): boolean { try { ensureSecureDir(getDataDir()); writeSecureFileAtomic( getUpdateCacheFile(), - JSON.stringify({ ...cache, notifiedFor: cache.notifiedFor.slice(-MAX_NOTIFIED_ENTRIES) }, null, 2), + JSON.stringify({ ...cache, notifiedFor: [...new Set(cache.notifiedFor)] }, null, 2), ); return true; } catch { @@ -722,26 +746,9 @@ function writeCacheForGeneration( return false; } -/** Ephemeral owner-only lock beside the update cache. */ -function getRefreshLockFile(): string { - return `${getUpdateCacheFile()}.lock`; -} - -/** Serializes recovery of an abandoned refresh lock. */ -function getRefreshRecoveryLockFile(): string { - return `${getRefreshLockFile()}.recovery`; -} - -/** - * Whether an owner-only lock path currently exists. A security/read error is - * treated as "present" so acquisition fails closed. - */ -function secureLockExists(file: string): boolean { - try { - return readSecureFile(file) !== null; - } catch { - return true; - } +/** Prefix for unique owner-only lock contenders beside the update cache. */ +function getRefreshLockPrefix(): string { + return `${getUpdateCacheFile()}.lock-`; } /** Parsed ownership data for one of this module's transient lock files. */ @@ -786,22 +793,62 @@ function lockOwnerMayBeAlive(raw: string): boolean { } } +interface RefreshLockRecord { + readonly pid: number; + readonly createdAt: number; + readonly id: string; + readonly state: "choosing" | "ready"; + readonly ticket?: number; +} + +/** Parse one unique bakery-lock contender. Invalid metadata fails closed. */ +function refreshLockRecord(raw: string): RefreshLockRecord | null { + const owner = lockOwner(raw); + if (!owner) return null; + try { + const parsed = JSON.parse(raw) as Record; + if ( + typeof parsed["id"] !== "string" || + parsed["id"].length === 0 || + parsed["id"].length > 64 || + (parsed["state"] !== "choosing" && parsed["state"] !== "ready") + ) { + return null; + } + if ( + parsed["state"] === "ready" && + (!Number.isSafeInteger(parsed["ticket"]) || (parsed["ticket"] as number) <= 0) + ) { + return null; + } + return { + ...owner, + id: parsed["id"], + state: parsed["state"], + ticket: parsed["state"] === "ready" ? (parsed["ticket"] as number) : undefined, + }; + } catch { + return null; + } +} + /** * Remove abandoned temp inodes left if a process exited while atomically * publishing a lock. These names are never synchronization points, so deleting - * an old malformed temp is fail-closed: at worst its still-live creator's link - * operation fails and no request occurs. + * an old malformed temp is fail-closed: at worst its still-live creator's + * publication fails and no request occurs. */ function cleanupAbandonedUpdateTemps(now: number): void { const dir = getDataDir(); - const prefixes = [ - `${basename(getUpdateCacheFile())}.tmp-`, - `${basename(getRefreshLockFile())}.tmp-`, - `${basename(getRefreshRecoveryLockFile())}.tmp-`, - ]; + const cacheTempPrefix = `${basename(getUpdateCacheFile())}.tmp-`; + const lockPrefix = basename(getRefreshLockPrefix()); try { for (const name of readdirSync(dir)) { - if (!prefixes.some((prefix) => name.startsWith(prefix))) continue; + const isCacheTemp = name.startsWith(cacheTempPrefix); + const isLockTemp = name.startsWith(lockPrefix) && name.includes(".tmp-"); + if (!isCacheTemp && !isLockTemp) { + continue; + } const file = join(dir, name); try { const stat = lstatSync(file); @@ -824,100 +871,167 @@ function cleanupAbandonedUpdateTemps(now: number): void { } /** - * Remove an abandoned regular-file lock, but never follow or remove a symlink - * or non-file entry. Returns true only when the path is absent afterward. - * - * Refresh-lock callers invoke this while holding the recovery lock. Normal - * acquirers check that recovery lock both before and after creating the refresh - * lock, closing the stale-observer/replacement race. + * Return the exact contents of a regular-file lock only when it is old enough + * and its recorded local process has exited. Malformed, live, symlink, and + * unreadable entries fail closed. */ -function removeStaleLock(file: string, now: number): boolean { +function abandonedLockContents(file: string, now: number): string | null { try { - const first = readSecureFile(file); - if (first === null) return true; - + const raw = readSecureFile(file); + if (raw === null) return null; const stat = lstatSync(file); - if (stat.isSymbolicLink() || !stat.isFile()) return false; + if (stat.isSymbolicLink() || !stat.isFile()) return null; const age = now - stat.mtimeMs; - if (!Number.isFinite(age) || age < REFRESH_LOCK_STALE_MS) return false; - if (lockOwnerMayBeAlive(first)) return false; + if (!Number.isFinite(age) || age < REFRESH_LOCK_STALE_MS) return null; + return lockOwnerMayBeAlive(raw) ? null : raw; + } catch { + return null; + } +} - // Re-read the ownership token immediately before unlinking. This narrows - // the release/reacquire race so an old observer does not remove a successor - // process's lock. - if (readSecureFile(file) !== first) return false; - unlinkSync(file); - return true; - } catch (error) { - // A concurrent owner may have released the lock after our first check. - return (error as NodeJS.ErrnoException)?.code === "ENOENT"; +/** List fully published, unique bakery-lock contenders. */ +function listRefreshLocks(): string[] { + const prefix = basename(getRefreshLockPrefix()); + try { + return readdirSync(getDataDir()) + .filter((name) => name.startsWith(prefix) && !name.includes(".tmp-")) + .map((name) => join(getDataDir(), name)); + } catch { + // An unreadable directory must block acquisition, not enable it. + return [getRefreshLockPrefix()]; + } +} + +/** + * Remove abandoned unique lock contenders. + * + * Contender names are UUID-based and never reused. Deleting a proven-dead + * contender therefore cannot remove a successor synchronization object. + */ +function cleanupAbandonedRefreshLocks(now: number): void { + for (const marker of listRefreshLocks()) { + if (!abandonedLockContents(marker, now)) continue; + try { + unlinkSync(marker); + } catch { + // Another cleanup observer may already have removed the unique contender. + } + } +} + +type RefreshLockRead = + | { readonly state: "missing" } + | { readonly state: "invalid" } + | { readonly state: "present"; readonly record: RefreshLockRecord }; + +/** Read one contender while distinguishing disappearance from invalid state. */ +function readRefreshLock(file: string): RefreshLockRead { + try { + const raw = readSecureFile(file); + if (raw === null) { + return existsSync(file) ? { state: "invalid" } : { state: "missing" }; + } + const record = refreshLockRecord(raw); + return record ? { state: "present", record } : { state: "invalid" }; + } catch { + return { state: "invalid" }; } } /** * Atomically acquire the cross-process refresh lock. * - * Any filesystem/security error fails closed: no lock means no registry - * request. A stale lock is reclaimed once and acquisition is retried. + * This is a fail-fast Lamport bakery protocol over owner-only files: + * 1. publish a never-reused UUID contender in `choosing` state; + * 2. choose one more than the largest published ticket; + * 3. atomically publish that ticket; and + * 4. enter only when no contender is still choosing and no ready contender has + * the lower `(ticket, UUID)` tuple. + * + * A contender that starts after step 4 observes this process's ready ticket and + * loses. Concurrent choosers either observe one another or cause one/both calls + * to fail closed. Crashed contenders are reclaimed only through their unique + * paths after the owner is proven dead, so no observer ever unlinks or renames a + * shared pathname that a successor could have replaced. */ function acquireRefreshLock(now: number): string | null { - const file = getRefreshLockFile(); - const recoveryFile = getRefreshRecoveryLockFile(); - // The local PID is used only to prove that a stale lock owner is gone before - // replacement. It is transient, never enters the cache/request, and is - // disclosed in PRIVACY.md. - const token = JSON.stringify({ pid: process.pid, createdAt: now }); + const id = randomUUID(); + const file = `${getRefreshLockPrefix()}${id}`; + const choosing = JSON.stringify({ + pid: process.pid, + createdAt: now, + id, + state: "choosing", + }); + let acquired = false; try { ensureSecureDir(getDataDir()); cleanupAbandonedUpdateTemps(now); - - // A crashed recovery operation must not block forever. Multiple contenders - // may observe it as stale, but each revalidates the main lock under whichever - // recovery lock it obtains; a newly created main lock is never stale. - if (secureLockExists(recoveryFile) && !removeStaleLock(recoveryFile, now)) { - return null; + cleanupAbandonedRefreshLocks(now); + if (!tryCreateSecureFileExclusive(file, choosing)) return null; + + let maxTicket = 0; + for (const contender of listRefreshLocks()) { + if (contender === file) continue; + const read = readRefreshLock(contender); + if (read.state === "missing") continue; + if (read.state === "invalid") return null; + if (read.record.state === "ready") { + maxTicket = Math.max(maxTicket, read.record.ticket ?? 0); + } } + if (!Number.isSafeInteger(maxTicket) || maxTicket >= Number.MAX_SAFE_INTEGER) return null; + + const ticket = maxTicket + 1; + writeSecureFileAtomic( + file, + JSON.stringify({ + pid: process.pid, + createdAt: now, + id, + state: "ready", + ticket, + }), + ); - // Fast path. Re-check recovery AFTER the atomic create: if a stale-lock - // reclaimer raced us, surrender our lock before any cache write or egress. - if (tryCreateSecureFileExclusive(file, token)) { - if (secureLockExists(recoveryFile)) { - releaseLock(file, token); + for (const contender of listRefreshLocks()) { + if (contender === file) continue; + const read = readRefreshLock(contender); + if (read.state === "missing") continue; + if (read.state === "invalid" || read.record.state === "choosing") return null; + const otherTicket = read.record.ticket; + if ( + otherTicket === undefined || + otherTicket < ticket || + (otherTicket === ticket && read.record.id < id) + ) { return null; } - return token; } - const recoveryToken = token; - if (!tryCreateSecureFileExclusive(recoveryFile, recoveryToken)) return null; - try { - // Revalidate under the recovery lock. If another contender replaced the - // stale lock, its fresh mtime prevents us from removing it. - if (!removeStaleLock(file, now)) return null; - return tryCreateSecureFileExclusive(file, token) ? token : null; - } finally { - releaseLock(recoveryFile, recoveryToken); - } + acquired = true; + return file; } catch { return null; + } finally { + if (!acquired) releaseRefreshLock(file); } } -/** Release only the lock at `file` created with `token`. */ -function releaseLock(file: string, token: string): void { +/** + * Release this caller's never-reused contender path. + * + * No token reread is needed: the protocol never creates a successor at this + * UUID pathname, which removes the read-then-unlink pathname race entirely. + */ +function releaseRefreshLock(file: string): void { try { - if (readSecureFile(file) !== token) return; unlinkSync(file); } catch { - // Best-effort. A surviving lock is reclaimed after REFRESH_LOCK_STALE_MS. + // Best-effort. A surviving contender is reclaimed after the owner exits. } } -/** Release only the refresh lock created by this caller. */ -function releaseRefreshLock(token: string): void { - releaseLock(getRefreshLockFile(), token); -} - /** Whether a cache entry is still authoritative for `registry` and this build. */ function isCacheFresh(cache: UpdateCache, registry: string, now: number): boolean { if (cache.currentVersion !== PACKAGE_VERSION) return false; @@ -1149,7 +1263,11 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { } const now = Date.now(); - const cached = readCache(); + // Capture logout/reset's generation before the first cache read. If it + // advances anywhere before the under-lock reread, this run started on a + // superseded view and must not adopt the new generation or recreate state. + const deletionGenerationAtStart = readDeletionGeneration(); + let cached = readCache(); let fresh = cached && isCacheFresh(cached, registry, now) ? cached : null; let refreshLock: string | null = null; let deletionGeneration: string | null = null; @@ -1160,6 +1278,21 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { // Another process may have completed between our first cache read and // lock acquisition. Re-check under the lock before reserving a request. const afterLock = readCache(); + // Even when this entry is stale, its suppression history is now the + // authoritative one. A notice claim may have landed after `cached` was + // read but before this lock was acquired; carrying the initial history + // forward would erase that claim and permit a duplicate notice. + cached = afterLock; + if (readDeletionGeneration() !== deletionGenerationAtStart) { + releaseRefreshLock(refreshLock); + refreshLock = null; + status = { + enabled: true, + state: "unavailable", + currentVersion: PACKAGE_VERSION, + }; + return; + } fresh = afterLock && isCacheFresh(afterLock, registry, now) ? afterLock : null; } else { // The lock owner may have completed between contention and this read. @@ -1183,7 +1316,7 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { releaseRefreshLock(refreshLock); refreshLock = null; } - if (!fresh) deletionGeneration = readDeletionGeneration(); + if (!fresh) deletionGeneration = deletionGenerationAtStart; let tags: Record | null; let checkedAt: number; @@ -1271,13 +1404,20 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { const channel = releaseChannel(current); const channelVersion = newerTagVersion(tags ?? {}, channel, current); const stableVersion = newerStableVersion(tags ?? {}, current); - const channelTarget = + const rawChannelTarget = channel && channelVersion ? ({ kind: "channel", channel, version: channelVersion } as const) : undefined; const stableTarget = stableVersion ? ({ kind: "stable", version: stableVersion } as const) : undefined; + // A channel dist-tag is allowed to point at a GA. When it converges with + // `latest`, represent the target once as stable so the notice names + // `@latest` and only the stable suppression key is consumed. + const channelTarget = + rawChannelTarget?.version === stableTarget?.version + ? undefined + : rawChannelTarget; const targets: NoticeTarget[] = [ ...(channelTarget ? [channelTarget] : []), ...(stableTarget ? [stableTarget] : []), @@ -1312,11 +1452,9 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { updateAvailable: update, }; - // Per-target suppression: each newer version is announced exactly once, even - // across restarts. The suppression entry is persisted by - // `takePendingUpdateNotice()` at *delivery* time, not here: a process that - // probes and then exits before any tool call would otherwise burn the only - // announcement without the user ever seeing it. + // Per-target suppression is claimed atomically by + // `takePendingUpdateNotice()`, not here: a process that probes and then exits + // before any tool call leaves the announcement available to another process. const unnotifiedTargets = targets.filter( (target) => !isTargetNotified(notifiedFor, target), ); @@ -1338,9 +1476,8 @@ async function runUpdateCheck(options: StartUpdateCheckOptions): Promise { if (!noticeUpdate) return; pendingNotice = { - text: renderNotice(noticeUpdate), - updateAvailable: noticeUpdate, - suppressionKeys: unnotifiedTargets.map(targetSuppressionKey), + channel, + targets: unnotifiedTargets, }; logger.debug(`Update available: ${PACKAGE_VERSION} -> ${latest}`); } catch { @@ -1379,17 +1516,17 @@ export function startUpdateCheck(options: StartUpdateCheckOptions = {}): void { } /** - * Take the pending notice, clearing it. + * Atomically claim and take the pending notice. * - * Returning-and-clearing is what makes the notice appear on exactly one tool - * result: whichever call happens to run after the probe resolves gets it, and - * every later call sees `null`. + * The claim is durably written before a notice is returned, so simultaneous + * processes sharing a data directory cannot both return the same target. If a + * process exits after that durable claim but before its MCP response reaches the + * client, the notice can be missed rather than duplicated; filesystem state + * alone cannot make response delivery transactional. * - * Cross-process suppression is persisted *here*, at delivery, rather than when - * the probe found the update: a process that exits before any tool call leaves - * the cache untouched, so the next process still announces the version. The - * cache is re-read immediately before writing under the same cross-process lock - * as refreshes, so concurrent entries are merged rather than clobbered. + * A transient lock/security failure keeps the process-local notice pending for a + * later tool call. An absent cache (for example after logout) drops the notice + * rather than returning something that cannot be claimed cross-process. * * An absent cache file is never re-created here: `spe-mcp logout` deletes it, * and delivery must not resurrect state the user just erased. @@ -1398,8 +1535,29 @@ export function takePendingUpdateNotice(): UpdateNotice | null { const pending = pendingNotice; pendingNotice = null; if (!pending) return null; - persistNotified(pending.suppressionKeys); - return { text: pending.text, updateAvailable: pending.updateAvailable }; + + const claim = claimNoticeTargets(pending.targets); + if (claim.state === "retry") { + pendingNotice ??= pending; + return null; + } + if (claim.state !== "claimed") return null; + + const channelTarget = claim.targets.find( + (target): target is Extract => + target.kind === "channel", + ); + const stableTarget = claim.targets.find( + (target): target is Extract => + target.kind === "stable", + ); + const updateAvailable = buildUpdateAvailable( + pending.channel, + channelTarget, + stableTarget, + ); + if (!updateAvailable) return null; + return { text: renderNotice(updateAvailable), updateAvailable }; } /** Namespaced suppression key so channel and stable targets advance independently. */ @@ -1423,28 +1581,46 @@ function isTargetNotified(notifiedFor: readonly string[], target: NoticeTarget): ); } -/** Record target keys as announced, serialized with refresh cache writes. */ -function persistNotified(suppressionKeys: readonly string[]): void { +type NoticeClaimResult = + | { readonly state: "claimed"; readonly targets: readonly NoticeTarget[] } + | { readonly state: "already-claimed" } + | { readonly state: "retry" }; + +/** + * Claim only the targets still unannounced in the authoritative cache. + * + * The read, comparison, and write all happen under the refresh lock. Returning + * the represented targets only after the atomic write is what prevents two + * processes from both returning the same notice. + */ +function claimNoticeTargets(targets: readonly NoticeTarget[]): NoticeClaimResult { let lock: string | null = null; try { - // No cache (never written, or deleted by logout) => do not create a lock or - // resurrect state the user intentionally removed. - if (!readCache()) return; + const beforeLock = readCacheResult(); + if (beforeLock.state === "unreadable") return { state: "retry" }; + if (beforeLock.state !== "value") return { state: "already-claimed" }; lock = acquireRefreshLock(Date.now()); - if (!lock) return; + if (!lock) return { state: "retry" }; const deletionGeneration = readDeletionGeneration(); - const cache = readCache(); - // No cache (never written, or deleted by logout) => nothing to update. - if (!cache) return; - const missing = suppressionKeys.filter((key) => !cache.notifiedFor.includes(key)); - if (missing.length === 0) return; - writeCacheForGeneration( - { ...cache, notifiedFor: [...cache.notifiedFor, ...missing] }, + const afterLock = readCacheResult(); + if (afterLock.state === "unreadable") return { state: "retry" }; + if (afterLock.state !== "value") return { state: "already-claimed" }; + const cache = afterLock.cache; + const unclaimed = targets.filter( + (target) => !isTargetNotified(cache.notifiedFor, target), + ); + if (unclaimed.length === 0) return { state: "already-claimed" }; + const suppressionKeys = unclaimed.map(targetSuppressionKey); + const persisted = writeCacheForGeneration( + { ...cache, notifiedFor: [...cache.notifiedFor, ...suppressionKeys] }, deletionGeneration, ); + return persisted + ? { state: "claimed", targets: unclaimed } + : { state: "retry" }; } catch { - // Best-effort: at worst the notice is shown once more next run. + return { state: "retry" }; } finally { if (lock) releaseRefreshLock(lock); } @@ -1512,8 +1688,8 @@ export const __testing = { readCappedText, readCache, writeCache, - getRefreshLockFile, - getRefreshRecoveryLockFile, + getRefreshLockPrefix, + listRefreshLocks, getDeletionGenerationFile, acquireRefreshLock, releaseRefreshLock, diff --git a/src/update-notice-e2e.test.ts b/src/update-notice-e2e.test.ts index 6d2835c..068f6c3 100644 --- a/src/update-notice-e2e.test.ts +++ b/src/update-notice-e2e.test.ts @@ -8,10 +8,12 @@ * that only a real process boundary can show: the single announcement of a newer * version survives a server that probes and then exits before any tool call. * - * Shape of the run (three sequential servers, one shared data directory): + * Shape of the run (sequential and simultaneous servers, one shared data directory): * 1. connect, then close without calling a tool => nothing is burned * 2. restart, call a tool => exactly one notice * 3. restart, call a tool => silence forever after + * 4. two simultaneous first tool calls => exactly one notice + * 5. many stale-lock reclaimers => exactly one request * * Hermetic by construction: * - the update cache is pre-seeded as a *fresh success*, so the server reuses it @@ -28,17 +30,25 @@ * copy carries a sibling `package.json` so the runtime version lookup resolves. */ -import { execSync } from "node:child_process"; -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { execSync, spawn } from "node:child_process"; +import { + cpSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + utimesSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { parseSemver, releaseChannel } from "./semver.js"; import { ensureSecureDir, writeSecureFile } from "./secure-fs.js"; -import { DEFAULT_REGISTRY } from "./update-check.js"; +import { DEFAULT_REGISTRY, REFRESH_LOCK_STALE_MS } from "./update-check.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); @@ -111,9 +121,12 @@ function seedCache(notifiedFor: string[] = []): void { ); } -function readCacheFile(): { notifiedFor?: unknown } | null { +function readCacheFile(): { notifiedFor?: unknown; outcome?: unknown } | null { if (!existsSync(cacheFile())) return null; - return JSON.parse(readFileSync(cacheFile(), "utf8")) as { notifiedFor?: unknown }; + return JSON.parse(readFileSync(cacheFile(), "utf8")) as { + notifiedFor?: unknown; + outcome?: unknown; + }; } function childEnv(): Record { @@ -166,6 +179,34 @@ async function stopServer(client?: Client, transport?: StdioClientTransport): Pr } } +function runNodeChild(script: string): Promise<{ pid: number; stderr: string }> { + return new Promise((resolveChild, rejectChild) => { + const child = spawn(process.execPath, ["--input-type=module", "--eval", script], { + cwd: REPO_ROOT, + env: childEnv(), + stdio: ["ignore", "ignore", "pipe"], + }); + const pid = child.pid; + if (pid === undefined) { + rejectChild(new Error("spawned child did not receive a process ID")); + return; + } + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", rejectChild); + child.once("close", (code) => { + if (code === 0) { + resolveChild({ pid, stderr }); + } else { + rejectChild(new Error(`spawned child ${pid} exited ${String(code)}: ${stderr}`)); + } + }); + }); +} + /** Call the safe, no-network tool and return its text + structured payload. */ async function callSafeTool( client: Client, @@ -272,4 +313,104 @@ describe("update notice delivery over spawned JSON-RPC (AB#3219517)", () => { EXPECTED_NOTIFICATION_KEYS, ); }, 60000); + + it("atomically delivers one notice across simultaneous server processes", async () => { + seedCache([]); + const servers = await Promise.all([startServer(), startServer()]); + try { + const results = await Promise.all(servers.map(({ client }) => callSafeTool(client))); + expect( + results.filter(({ text }) => /Update available:/i.test(text)), + "only one process may claim and return the shared pending target", + ).toHaveLength(1); + expect( + results.filter(({ structured }) => structured?.["updateAvailable"] !== undefined), + "the structured notice must have the same single delivery", + ).toHaveLength(1); + } finally { + await Promise.all(servers.map(({ client, transport }) => stopServer(client, transport))); + } + + expect(readCacheFile()?.notifiedFor).toEqual(EXPECTED_NOTIFICATION_KEYS); + }, 60000); + + it("allows only one request while processes race to recover a stale lock", async () => { + rmSync(cacheFile(), { force: true }); + for (const name of readdirSync(dataDir)) { + if (name.startsWith("request-")) rmSync(join(dataDir, name), { force: true }); + } + + // Use an actual exited child PID rather than guessing a nonexistent PID, + // making the liveness proof portable across Windows, Linux, and macOS. + const exited = await runNodeChild(""); + const staleCreatedAt = Date.now() - REFRESH_LOCK_STALE_MS - 1_000; + const lockFile = `${cacheFile()}.lock-stale-fixture`; + writeSecureFile( + lockFile, + JSON.stringify({ + pid: exited.pid, + createdAt: staleCreatedAt, + id: "stale-fixture", + state: "ready", + ticket: 1, + }), + ); + const staleDate = new Date(staleCreatedAt); + utimesSync(lockFile, staleDate, staleDate); + + const moduleUrl = pathToFileURL(join(REPO_ROOT, "dist", "update-check.js")).href; + const tags = { + latest: SEED_LATEST, + ...(CHANNEL_TAG && SEED_CHANNEL_VERSION + ? { [CHANNEL_TAG]: SEED_CHANNEL_VERSION } + : {}), + }; + const worker = ` + const { existsSync, writeFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + writeFileSync(join(process.env.SPE_DATA_DIR, \`ready-\${process.pid}\`), "1"); + while (!existsSync(join(process.env.SPE_DATA_DIR, "start-race"))) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + globalThis.fetch = async () => { + writeFileSync(join(process.env.SPE_DATA_DIR, \`request-\${process.pid}\`), "1"); + await new Promise((resolve) => setTimeout(resolve, 350)); + return new Response(${JSON.stringify(JSON.stringify({ "dist-tags": tags }))}, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const { __testing } = await import(${JSON.stringify(moduleUrl)}); + __testing.setInstalled(true); + await __testing.runUpdateCheck({}); + `; + + const workers = Array.from({ length: 8 }, () => runNodeChild(worker)); + await vi.waitFor( + () => { + expect( + readdirSync(dataDir).filter((name) => name.startsWith("ready-")), + "every child must reach the barrier before stale recovery starts", + ).toHaveLength(8); + }, + { timeout: 10_000 }, + ); + writeSecureFile(join(dataDir, "start-race"), "go"); + await Promise.all(workers); + + expect( + readdirSync(dataDir).filter((name) => name.startsWith("request-")), + "stale recovery and the pre-egress reservation must admit one requester", + ).toHaveLength(1); + expect(readCacheFile(), "the winning process should publish a usable cache").toMatchObject({ + outcome: "success", + }); + expect( + readdirSync(dataDir).filter( + (name) => + name.startsWith("update-check.json.lock-"), + ), + "no reclaimer may delete a successor lock or strand takeover artifacts", + ).toEqual([]); + }, 60000); });