diff --git a/CHANGELOG.md b/CHANGELOG.md index ec93124..5468c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,54 @@ 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` + (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 + 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. 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.** `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 + 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. `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 + 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`, + `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 @@ -18,6 +66,22 @@ 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 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` / + `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/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 7ef19b5..77cce22 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,7 +12,10 @@ 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 destination that is **not a Microsoft 365 or Azure +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 "phone home." Diagnostic logs are written to the local process's **stderr only**, with @@ -32,6 +35,83 @@ 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 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)**. 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 + fragment: + + ```text + GET https://registry.npmjs.org/@microsoft%2fspe-mcp + ``` + + **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 | + |------------------|-----| + | 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 | + | 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 + 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; 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 + 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 + 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. @@ -42,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 @@ -68,7 +153,33 @@ 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 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 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` | **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 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` +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..feedc20 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,91 @@ 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 + +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). +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 +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. +- **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 + 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 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 + the path. + +> ⚠️ **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 +> 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 +> 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 # legacy alias +NO_UPDATE_NOTIFIER=1 spe-mcp start # community-standard opt-out +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 +at all**. See [PRIVACY.md](PRIVACY.md) and +[docs/DATA-FLOW.md](docs/DATA-FLOW.md) for the full data-flow description. ## Prerequisites @@ -198,7 +279,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` (**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. @@ -278,7 +361,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 +379,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,13 +686,46 @@ 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 +`@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 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 +what `npm view` would issue. As with any HTTPS request, npm can observe your **IP address**, +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` (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 +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 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 @@ -624,10 +741,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/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 ee36255..8756ac6 100644 --- a/docs/DATA-FLOW.md +++ b/docs/DATA-FLOW.md @@ -13,8 +13,15 @@ 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 unauthenticated public-package + 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 @@ -24,10 +31,33 @@ 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** | -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 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 + 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 + **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. ## Local artifacts @@ -35,6 +65,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 +78,14 @@ 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 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. - 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 +99,9 @@ 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 -details. +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 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/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md index 56f5d63..0f9f257 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), 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 55aef35..52e6231 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -89,6 +89,44 @@ 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` (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 + 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/package.json b/package.json index 7db0ffd..59abceb 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,17 @@ "files": [ "dist", "samples", - "README.md", + "docs/DATA-FLOW.md", + "docs/SECURITY-CONTROLS.md", + "docs/TROUBLESHOOTING.md", + "CHANGELOG.md", + "CONTRIBUTING.md", "LICENSE", + "NOTICE.md", + "PRIVACY.md", + "README.md", + "SECURITY.md", + "SUPPORT.md", "THIRD-PARTY-NOTICES" ], "scripts": { diff --git a/src/cli.ts b/src/cli.ts index 0e3e73d..66299be 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_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: { + 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-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 4e3a013..639f285 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,37 @@ 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. + * + * 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 }[], + 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 } + : { updateAvailable }; + 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 +365,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 +476,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 @@ -497,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/packaging.test.ts b/src/packaging.test.ts index 82578b6..d383bc6 100644 --- a/src/packaging.test.ts +++ b/src/packaging.test.ts @@ -71,6 +71,129 @@ 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", + "CONTRIBUTING.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); + }); + + /** + * 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 + * 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", () => { @@ -92,6 +215,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..82773b8 100644 --- a/src/protocol-e2e.test.ts +++ b/src/protocol-e2e.test.ts @@ -79,6 +79,13 @@ 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 — 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({ command: process.execPath, @@ -210,4 +217,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..b84fa11 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 — 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)"; + 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..f5ac13b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,16 @@ 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 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; } // ─── Auth Config ───────────────────────────────────────────────────────────── diff --git a/src/update-check.test.ts b/src/update-check.test.ts new file mode 100644 index 0000000..8f75469 --- /dev/null +++ b/src/update-check.test.ts @@ -0,0 +1,1484 @@ +// 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, + collectionNotice, + 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 same 24h boundary as a success", () => { + const failure = { ...base, outcome: "failure" as const }; + // 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), + ).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 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", + 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(`${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 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, 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", () => { + 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", + 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", () => { + const text = __testing.renderNotice({ + package: PACKAGE_NAME, + current: "1.0.0", + latest: "1.1.0", + channel: null, + packageSpec: `${PACKAGE_NAME}@latest`, + }); + expect(text).not.toContain("channel)"); + expect(text).toContain(`${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?.packageSpec).toBe(`${PACKAGE_NAME}@alpha`); + + 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); + expect(takePendingUpdateNotice()).not.toBeNull(); + + __testing.reset(); + __testing.setInstalled(true); + await __testing.runUpdateCheck({}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getUpdateStatus().state).toBe("update-available"); + // Already delivered before the restart, so it is not repeated. + 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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())); + 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(); + }); + + // 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({}); + + 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()); + }); +}); + +// --------------------------------------------------------------------------- +// 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 new file mode 100644 index 0000000..5ef72c0 --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,1034 @@ +// 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. 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**, 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. + * - Before the FIRST network request of a process, a one-time collection 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 + * 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 (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` / + * `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. + * - 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`, + * `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. + * + * 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; + +/** 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"); + +/** + * 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. + * + * @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 = collectionNotice(DEFAULT_REGISTRY); + +/** 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 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. */ +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"); +} + +/** + * Whether a value is a tag name we are willing to use as a map key. + * + * Rejects non-strings, empty/oversized names, and prototype-pollution keys such + * as `__proto__`. Applied to both freshly fetched packument keys and to tag + * names read back from the on-disk cache, which is treated as untrusted input. + */ +function isSafeTagName(name: unknown): name is string { + if (typeof name !== "string") return false; + if (name.length === 0 || name.length > 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. + * + * 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 = emptyTagMap(); + + 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 (!isSafeTagName(name)) continue; + if (typeof value !== "string" || value.length > MAX_TAG_VALUE_LENGTH) continue; + if (parseSemver(value) === null) continue; + result[name] = value; + } + + return result; +} + +/** + * 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(registry: string): void { + if (collectionNoticeEmitted) return; + collectionNoticeEmitted = true; + logger.log(collectionNotice(registry)); +} + +/** + * 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 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, + registry: 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(registry); + + 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, + // 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), + }; + } 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 = buildTagsFromCache(cache); + 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. + * + * 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)` : ""}.`, + `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, 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"); +} + +// --------------------------------------------------------------------------- +// 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, registry); + 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, stablePackageSpec: `${PACKAGE_NAME}@latest` } + : {}), + packageSpec: `${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. 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 }; + 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 = emptyTagMap(); + if (cache.latest) tags["latest"] = cache.latest; + if (isSafeTagName(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. + * + * 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 { + // 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`. + * + * 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 (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(); + + // 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: cache?.registry ?? resolveRegistry() ?? DEFAULT_REGISTRY, + latestVersion: cache ? cachedTargetVersion(cache) : undefined, + 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/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); +}); diff --git a/src/version.ts b/src/version.ts index 3811025..e58cabc 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 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;