diff --git a/overrides/claude/glean/hooks/capture-inventory.mjs b/overrides/claude/glean/hooks/capture-inventory.mjs new file mode 100644 index 0000000..bfbd1d7 --- /dev/null +++ b/overrides/claude/glean/hooks/capture-inventory.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node +// SessionStart hook: capture the configured-MCP-server inventory from the host's own +// CLI and leave it where the MCP server can read it. +// +// node capture-inventory.mjs +// +// CLAUDE CODE ONLY. Codex was wired the same way -- its own `codex mcp list --json` parser +// works, and its plugin.json carried a `hooks` pointer -- but Codex never invokes the hook. +// Its trace log, at DEBUG level and 699k rows, mentions SessionStart, the hook filename and +// the manifest exactly zero times, so it is not a sandbox or a path problem: this build does +// not run plugin-declared hooks at all. That work is preserved on +// mohit/inventory-codex-followup rather than shipped unreachable, and Codex reports +// `unavailable` in the meantime, which is a defined value. +// +// WHY A HOOK AT ALL. `claude mcp list` health-checks every server, and health-checking a +// stdio server means spawning it -- including this plugin. Verified: the spawned copy +// goes on to serve a full tools/list with a live remote fetch. Calling the CLI from the +// request path would therefore recurse without bound, one process and one backend call +// per level. A hook is a separate process that the MCP server never invokes, so the +// recursion has no edge to travel along. +// +// Failure is never fatal and never loud: the hook exits 0 whatever happens, because a +// hook that cannot capture an inventory must not be a hook that breaks a session. But it +// is no longer silent. When the capture runs and comes back with nothing it writes a +// negative marker carrying an enumerated reason, so "the hook never fired" and "the hook +// fired and the CLI was missing" stop looking identical. Only codes are written, never an +// error string: an exec failure carries an absolute binary path, which on a normal install +// contains the user's name. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// How long the whole capture may take. The CLI spawns and health-checks every +// configured server on Claude Code, so this scales with server count and network +// latency; the hook is declared async so the wait costs the session nothing. +const CLI_TIMEOUT_MS = 60_000; + +function readStdin() { + try { + return fs.readFileSync(0, "utf-8"); + } catch { + return ""; + } +} + +/** + * Base directory shared with the MCP server. + * + * MUST match inventoryCachePath() in src/policy/inventory-cache.ts. This process does + * not inherit the server's env, so it cannot see the PLUGIN_DATA_DIR that start.mjs + * derives; CLAUDE_PLUGIN_DATA (else ~/.glean) is the one anchor both sides have. + */ +function dataDir() { + return process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".glean"); +} + +/** Origin plus path only: query and fragment can carry tokens. */ +function safeUrl(raw) { + try { + const u = new URL(raw); + return `${u.origin}${u.pathname === "/" ? "" : u.pathname}`; + } catch { + return undefined; + } +} + +/** + * Whether a URL is one of Glean's own. + * + * Glean's own domain only, for v0. Admitting the host the plugin is configured against + * would additionally cover white-labeled deployments, but it admits everything else + * sharing that host too: a customer fronting several MCP servers off one gateway under + * different paths would have the unrelated ones reported, origin and path. Matching the + * exact host does not help there, because it is the same host. + * + * So the trade runs one way. Under-reporting a white-labeled instance costs the remote + * some visibility; over-reporting discloses a customer's internal estate. + */ +function isGleanUrl(target) { + if (typeof target !== "string") return false; + let host; + try { + host = new URL(target).hostname.toLowerCase(); + } catch { + return false; + } + return host === "glean.com" || host.endsWith(".glean.com"); +} + +// ------------------------------------------------------------------ Claude Code + +// Shell ALIASES are a non-issue: execFile does not spawn a shell, so +// `alias claude='claude --dangerously-skip-permissions'` never applies. PATH is the real +// concern, and CLAUDE_CODE_EXECPATH is a better anchor -- it is exported into the MCP +// server's environment and points at the real binary (a Mach-O executable despite its +// .exe name). Undocumented, so it is a hint rather than a contract, but when present it +// sidesteps PATH entirely. +// +// WINDOWS: an npm global install exposes claude.cmd, and since the CVE-2024-27980 fix +// Node refuses to spawn .cmd/.bat without shell:true. So a bare-name invocation needs +// the shell there, which is a further reason to prefer an absolute path. +function claudeCandidates() { + const candidates = []; + if (process.env.CLAUDE_CODE_EXECPATH) { + candidates.push({ file: process.env.CLAUDE_CODE_EXECPATH, shell: false }); + } + candidates.push({ file: "claude", shell: process.platform === "win32" }); + return candidates; +} + +/** + * Parse one `claude mcp list` line. Returns undefined if it does not fit the format. + * + * Two traps, both of which a naive regex drops silently: + * - the NAME can contain colons (`plugin:glean-vnext:glean`), so the delimiter is the + * first colon followed by whitespace, not the first colon. + * - the `(TRANSPORT)` parenthetical is present for remote servers and ABSENT for + * stdio ones. + * + * The status separator is located from the right because a command can contain flags; + * status text uses an em dash (`— -32000: …`) rather than " - ", so the rightmost + * " - " is the separator. + */ +function parseClaudeMcpLine(line) { + const head = /^(?.+?):\s+(?.+)$/.exec(line); + if (!head) return undefined; + const { name, rest } = head.groups; + + const sep = rest.lastIndexOf(" - "); + if (sep < 0) return undefined; + const status = rest.slice(sep + 3).trim(); + let target = rest.slice(0, sep).trim(); + + const transport = /\s*\((?[^)]+)\)$/.exec(target); + if (transport) target = target.slice(0, transport.index).trim(); + + return { + name: name.trim(), + // Only a remote server can be confirmed as Glean's, so the stdio target -- a launch + // command -- is not carried past this point. It would disclose filesystem layout for + // no policy benefit. + url: transport ? target : undefined, + status, + }; +} + +// `✔ Connected` is treated as authenticated: for a remote server that requires auth, +// having connected means auth succeeded. The two never-connected states say nothing +// about credentials, so they map to unknown rather than to unauthenticated -- claiming a +// server is unauthenticated when it was merely unapproved would be a wrong answer, not +// a cautious one. +function claudeAuthStatus(status) { + const s = status.toLowerCase(); + if (s.includes("connected")) return "authenticated"; + if (s.includes("authenticat")) return "unauthenticated"; + return "unknown"; +} + +async function claudeMcpList(cwd) { + const run = await runCli(claudeCandidates(), ["mcp", "list"], cwd); + if (!run.ok) return { reason: "cli-unavailable" }; + + const rows = []; + for (const line of run.stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("Checking MCP server health")) continue; + if (/^No MCP servers configured/i.test(trimmed)) return { rows: [] }; + const parsed = parseClaudeMcpLine(trimmed); + // All-or-nothing. One unrecognized line means the format shifted, and a truncated + // inventory is indistinguishable from a user who genuinely has fewer servers -- so + // it is discarded entirely in favour of `unavailable`. + if (!parsed) return { reason: "cli-output-invalid" }; + rows.push({ + name: parsed.name, + url: parsed.url ? safeUrl(parsed.url) : undefined, + authStatus: claudeAuthStatus(parsed.status), + }); + } + return { rows }; +} + +// ------------------------------------------------------------------------ Codex + +// ------------------------------------------------------------------------ shared + +async function runCli(candidates, args, cwd) { + for (const { file, shell } of candidates) { + try { + const { stdout } = await execFileAsync(file, args, { + cwd, + timeout: CLI_TIMEOUT_MS, + encoding: "utf-8", + shell, + // NO_COLOR because ANSI escapes would defeat the text parsing, and the + // all-or-nothing rule would then discard a perfectly good inventory. + env: { ...process.env, NO_COLOR: "1" }, + }); + return { ok: true, stdout }; + } catch { + // Try the next candidate. A CLI that is absent, unrunnable, or times out is + // indistinguishable from a host that has none, and both mean `unavailable`. + } + } + return { ok: false }; +} + +/** + * Leave a trace that this ran, and how it ended. + * + * Without it the hook is invisible: it writes a capture or it writes nothing, so "the host + * never fired me" and "I fired and bailed" look identical from the server side -- and that + * is exactly the question when a host's hook support is unconfirmed. The server-side reader + * reports its own reasons in detail; leaving the writer mute made the pair asymmetric. + * + * Shares the server's log file, since that is where anyone diagnosing this is already + * looking. Appends only, like the server does, and carries no paths and no server names -- + * a count is enough, and the log outlives the capture it describes. + */ +function logHook(detail) { + try { + const line = `${new Date().toISOString()} [${process.pid}] inventory-hook ${JSON.stringify(detail)}\n`; + fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 }); + fs.appendFileSync(path.join(dataDir(), "glean-server.log"), line, { mode: 0o600 }); + } catch { + // Diagnostics must never be the reason a hook fails. + } +} + +async function main() { + let payload = {}; + try { + payload = JSON.parse(readStdin()); + } catch { + logHook({ outcome: "unreadable-payload" }); + return; + } + + const sessionId = String(payload.session_id ?? "") + .replace(/[^a-zA-Z0-9_-]/g, "-") + .slice(0, 64); + // Without a session id there is no key the server would look under, so a capture + // could only be written somewhere nothing reads. + if (!sessionId) { + // The failure a host that names its identifier differently would produce, so it is + // called out rather than folded into a generic bail. + logHook({ outcome: "no-session-id", payloadKeys: Object.keys(payload).sort() }); + return; + } + + // The CLIs are directory-sensitive, so the session's cwd is passed explicitly rather + // than inherited from wherever the hook happened to be spawned. Within one project + // the server SET is stable, but per-server approval state is not: the same server + // reports Connected from a repo root and Pending approval from a subdirectory. + const cwd = + typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd(); + + const outcome = await claudeMcpList(cwd); + + // A negative marker rather than no file at all. "No capture happened" and "the capture + // ran and came back with nothing" are different facts with different fixes -- the first + // means the hook never fired, the second means the CLI could not be found or its output + // was not understood -- and writing nothing made them indistinguishable, both on the + // wire and in the log. Only enumerated codes are written: an exec error would carry an + // absolute binary path, which on a normal install contains the user's name. + if (outcome.reason) { + writeCache(sessionId, { source: "unavailable", reason: outcome.reason }); + logHook({ outcome: outcome.reason }); + return; + } + + const servers = []; + let withheld = 0; + for (const row of outcome.rows) { + // A stdio server exposes no URL, so it can never be confirmed as Glean's and is + // always withheld -- including this plugin's own entry. Reporting ours would add + // nothing: `plugin.id` and `plugin.version` in the same request already say the + // plugin is here, and the remote is talking to it. + if (!row.url || !row.name || !isGleanUrl(row.url)) { + withheld += 1; + continue; + } + // Built field by field. The rows above already drop everything else, and this is the + // second place that is true, so a field added to a row cannot become a field in the + // payload by accident. + servers.push({ name: row.name, url: row.url, authStatus: row.authStatus }); + } + + writeCache(sessionId, { source: "host-cli", servers, withheld }); + logHook({ outcome: "host-cli", servers: servers.length, withheld }); +} + +/** + * Write the capture. + * + * A plain write, not the temp-and-rename dance src/atomic-write.ts does for the stores. + * Those files are shared: several processes read and modify them, so a torn write there + * discards every entry until something rewrites it. This one is written exactly once, by + * one process, under a filename keyed to the session -- there is no second writer to race. + * + * The remaining window is a reader catching the truncate: `writeFileSync` truncates before + * writing, and the server does read while a session is starting. That costs one request, + * which reports `capture-invalid` and recovers on the next one, for a field that is + * optional by contract. Not worth a temp file and its cleanup. + */ +function writeCache(sessionId, body) { + const dir = path.join(dataDir(), "inventory"); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + // mkdirSync applies `mode` only to directories it creates, and umask can mask it, so an + // existing directory could otherwise stay world-readable. The capture holds server names + // and URLs, which is exactly what the filter exists to keep narrow. + fs.chmodSync(dir, 0o700); + + const file = path.join(dir, `${sessionId}.json`); + fs.writeFileSync( + file, + JSON.stringify({ ...body, capturedAt: new Date().toISOString() }, null, 2), + { encoding: "utf-8", mode: 0o600 }, + ); + // Same reason as the directory: `mode` applies on creation only, and SessionStart fires + // again on resume, rewriting an existing file. + fs.chmodSync(file, 0o600); +} + +try { + await main(); +} catch { + // A hook that cannot capture the inventory must not be a hook that breaks the + // session. Absence is a valid answer; a non-zero exit here would only produce noise + // in the transcript for a field the remote treats as optional. +} +process.exit(0); diff --git a/overrides/claude/glean/hooks/hooks.json b/overrides/claude/glean/hooks/hooks.json index f135e7b..f3470f6 100644 --- a/overrides/claude/glean/hooks/hooks.json +++ b/overrides/claude/glean/hooks/hooks.json @@ -1,5 +1,18 @@ { "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "async": true, + "timeout": 90, + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture-inventory.mjs\"" + } + ] + } + ], "PreToolUse": [ { "matcher": "mcp__.*glean.*run_tool", diff --git a/shared/glean/mcp/src/data-dir.ts b/shared/glean/mcp/src/data-dir.ts new file mode 100644 index 0000000..f5a9a6c --- /dev/null +++ b/shared/glean/mcp/src/data-dir.ts @@ -0,0 +1,52 @@ +// The NAMED import, deliberately, matching every call site this replaced. Tests that +// redirect the home directory mock `node:os` as `{...actual, homedir}`, which overrides the +// named export and leaves the default export pointing at the real module -- so +// `import os from "node:os"` here would quietly read the developer's real ~/.glean while +// the test believed it was using a temp directory. +import { homedir } from "node:os"; +import path from "node:path"; + +/** + * Where the plugin keeps its state on disk. + * + * There are two answers, not one, and the difference is load-bearing enough that both + * live here rather than being spelled out at each call site. + * + * `start.mjs` reads whatever data directory the host provides and re-exports it as + * PLUGIN_DATA_DIR, so server code has a single variable to consult. Hooks get no such + * favour: they are separate processes the host spawns directly, so they never see + * PLUGIN_DATA_DIR and can only look at the host's own variable. Anything the server and a + * hook must agree on therefore has to key off the host variable on BOTH sides -- which is + * why picking the wrong one of these two functions produces a file written in one place + * and looked for in another, with no error anywhere. + * + * Under `start.mjs` the two resolve to the same directory, which is exactly what makes the + * mistake survive testing: they diverge only when PLUGIN_DATA_DIR is set and + * CLAUDE_PLUGIN_DATA is not. + */ +const DEFAULT_DIR = ".glean"; + +/** + * For state only this process touches: tokens, the URL config, the policy cache, the log. + * + * Prefers PLUGIN_DATA_DIR because that is the variable `start.mjs` normalizes the host's + * answer into, so this follows a managed data directory wherever the host puts it. + */ +export function serverDataDir(): string { + return process.env.PLUGIN_DATA_DIR || path.join(homedir(), DEFAULT_DIR); +} + +/** + * For state shared with a hook: the HITL permission-mode marker, the inventory capture. + * + * Deliberately does NOT consult PLUGIN_DATA_DIR, even though it usually holds the same + * value. A hook cannot see that variable, so preferring it here would mean the server + * looking somewhere the hook could never have written whenever the two differ. The + * duplicate of this expression in plugins/glean/hooks/*.mjs is the other half of the same + * agreement and cannot be shared as code -- those files are unbundled ESM the host runs + * directly, while this one is compiled into dist/. tests/inventory-cache.test.ts pins the + * two together by running the real hook and reading the result back through this module. + */ +export function hostSharedDataDir(): string { + return process.env.CLAUDE_PLUGIN_DATA || path.join(homedir(), DEFAULT_DIR); +} diff --git a/shared/glean/mcp/src/index.ts b/shared/glean/mcp/src/index.ts index ec88b3f..ed43b07 100644 --- a/shared/glean/mcp/src/index.ts +++ b/shared/glean/mcp/src/index.ts @@ -40,6 +40,7 @@ import { type DispatchContext, } from "./tools/remote-passthrough.js"; import { resolveSessionId } from "./session-id.js"; +import { serverDataDir } from "./data-dir.js"; import { resolveServerUrlFromEmail } from "./config-search.js"; import { pluginVersionString } from "./version.js"; import { @@ -97,8 +98,7 @@ const AUTH_REDIRECT_TO_SETUP_TEXT = "(no arguments) to sign in to Glean, then retry this tool."; function resolveLogPath(): string { - const base = process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); - return path.join(base, "glean-server.log"); + return path.join(serverDataDir(), "glean-server.log"); } const LOG_PATH = resolveLogPath(); diff --git a/shared/glean/mcp/src/policy/cache.ts b/shared/glean/mcp/src/policy/cache.ts index 0cc8d0e..518a40d 100644 --- a/shared/glean/mcp/src/policy/cache.ts +++ b/shared/glean/mcp/src/policy/cache.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import os from "node:os"; import { writeFileAtomicSync } from "../atomic-write.js"; +import { serverDataDir } from "../data-dir.js"; import type { PolicyResponse } from "./types.js"; // The last VALID policy, keyed by remote URL so switching instances does not @@ -21,11 +21,8 @@ interface PolicyCacheEntry { type PolicyCacheFile = Record; -// Same anchor as url-config-store and token-store: PLUGIN_DATA_DIR when the host -// provides a managed data directory, else ~/.glean. function cachePath(): string { - const base = process.env.PLUGIN_DATA_DIR || path.join(os.homedir(), ".glean"); - return path.join(base, "policy-cache.json"); + return path.join(serverDataDir(), "policy-cache.json"); } function readAll(): PolicyCacheFile { diff --git a/shared/glean/mcp/src/policy/context.ts b/shared/glean/mcp/src/policy/context.ts index 177b962..f835f3a 100644 --- a/shared/glean/mcp/src/policy/context.ts +++ b/shared/glean/mcp/src/policy/context.ts @@ -1,5 +1,6 @@ import { FEATURE_NAMES, type FeatureName } from "./key.js"; import { pluginVersion } from "../version.js"; +import { loadCachedInventory } from "./inventory-cache.js"; import type { ConfiguredServers, HostIdentity, @@ -34,25 +35,26 @@ export function hostIdentityFromHandshake( } /** - * The configured-MCP-server inventory. Not reported yet. + * The configured-MCP-server inventory, as captured by the SessionStart hook. * * Reconstructing it from host configuration files was evaluated and rejected: it means * reimplementing host merge semantics -- multiple config scopes, enablement and * approval state, plugin installation state, two different `.mcp.json` schemas, and * enterprise managed settings -- and every failure in that reimplementation is silent, - * producing a plausible list with wrong contents rather than an error. + * producing a plausible list with wrong contents rather than an error. That holds for + * Codex too: it merges plugin-contributed servers with its own config, and no host + * exposes per-server auth state on disk at all. * - * The accurate source is the host's own CLI (`claude mcp list`, `codex mcp list - * --json`), which is deferred because it cannot be called from this path: those - * commands health-check every server by spawning it, including this one, so invoking - * them during `tools/list` would make the plugin recursively launch itself. It needs a - * SessionStart hook that runs once per session and caches the result. + * The accurate source is the host's own CLI, which cannot be called from here -- see + * ./inventory-cache.ts for why doing so recurses into this plugin. So the hook captures + * it once per session and this reads the result. * - * Until then the field reports `unavailable`, which by contract says nothing about the - * user's setup rather than implying an empty list. + * `unavailable` is the answer on Cursor, which has no MCP CLI, and on any request that + * arrives before the capture lands. By contract it says nothing about the user's setup + * rather than implying an empty list. */ export function inventory(): ConfiguredServers { - return { source: "unavailable" }; + return loadCachedInventory(); } /** The features this build implements. Static: it changes only when a release does. */ diff --git a/shared/glean/mcp/src/policy/inventory-cache.ts b/shared/glean/mcp/src/policy/inventory-cache.ts new file mode 100644 index 0000000..c8bc9ac --- /dev/null +++ b/shared/glean/mcp/src/policy/inventory-cache.ts @@ -0,0 +1,316 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveSessionId } from "../session-id.js"; +import { hostSharedDataDir } from "../data-dir.js"; +import type { + AuthStatus, + ConfiguredServer, + ConfiguredServers, + InventoryUnavailableReason, +} from "./types.js"; + +/** + * Read side of the configured-server inventory. + * + * The inventory comes from the host's own CLI (`claude mcp list`, `codex mcp list + * --json`), which cannot be invoked from here. `claude mcp list` health-checks every + * server, and health-checking a stdio server means SPAWNING it -- including this + * plugin, whose own log shows the spawned copy going on to serve a full `tools/list` + * with a live remote fetch. So a shell-out from this module would recurse without + * bound, costing a process and a backend call per level. + * + * Hence the split: a SessionStart hook runs the CLI once per session, off the request + * path, and leaves the result here. **This module only ever reads a file.** That is the + * invariant the recursion argument rests on, and tests/inventory-cache.test.ts asserts + * it rather than trusting this comment. + */ + +/** What the hook writes. Kept in sync with hooks/capture-inventory.mjs by its tests. */ +interface InventoryCacheFile { + source?: unknown; + servers?: unknown; + withheld?: unknown; + /** The hook's own reason for having nothing, when source is `unavailable`. */ + reason?: unknown; + /** + * When the capture ran. A timestamp, and deliberately the only context recorded. + * + * The capture's working directory would be the other obvious thing to keep, because + * `claude mcp list` is directory-sensitive in a way that surprised us: within one + * project the server SET is stable, but per-server approval state is not -- the same + * server reports "Connected" from a repo root and "Pending approval" from a + * subdirectory. So a capture taken in the wrong directory yields a correct list with + * wrong statuses. + * + * It is still not recorded. A path is filesystem layout -- `/Users//...` carries + * a username, and project directory names carry customers' -- which is the same reason + * a stdio server's launch path is used for identification and then discarded. And the + * mismatch it would have diagnosed is one the session key already prevents, since the + * hook's cwd and this process's cwd are both pinned at session start. + */ + capturedAt?: unknown; +} + +const AUTH_STATUSES: ReadonlySet = new Set([ + "authenticated", + "unauthenticated", + "unknown", +]); + +// The hook may be from a different plugin version, so its reason is untrusted input like +// everything else in the file. Validated against the closed set rather than passed +// through, because this value goes onto the wire -- an unchecked string here would let a +// hand-edited file put arbitrary text into a negotiation request. +const HOOK_REASONS: ReadonlySet = new Set([ + "cli-unavailable", + "cli-output-invalid", +]); + +/** + * Fine-grained detail about the most recent read, for the local log only. + * + * Deliberately separate from the reason that goes on the wire. Field paths, offending + * value types and byte counts are what actually diagnose a rejection, and none of them + * belong in a request the remote receives. Never carries a value read out of the file: + * the file is the output of a privacy filter, so a rejected one is exactly the case where + * its contents are least trustworthy and most sensitive -- an older build's unfiltered + * list, or an entry still carrying the credential keys Codex emits verbatim. + */ +export interface InventoryDiagnostic { + detail: string; + entries?: number; + badIndex?: number; + badField?: string; + badType?: string; + badValue?: string; + bytes?: number; + /** The session key looked for, and how many captures exist under other keys. */ + sessionKey?: string; + otherCaptures?: number; +} + +let lastDiagnostic: InventoryDiagnostic | undefined; + +/** Detail for the most recent read, or undefined if it succeeded. */ +export function lastInventoryDiagnostic(): InventoryDiagnostic | undefined { + return lastDiagnostic; +} + +/** + * Path to the per-session inventory the SessionStart hook writes. + * + * Both halves of this path have to agree with a separate process: see hostSharedDataDir() + * in ../data-dir.js for the directory, and the same session-id sanitization is repeated in + * the hook. The agreement is pinned by a test rather than by this comment -- the hook is + * run for real and the result read back through here. + */ +export function inventoryCachePath(): string { + return path.join(inventoryDir(), `${sessionKey()}.json`); +} + +function inventoryDir(): string { + return path.join(hostSharedDataDir(), "inventory"); +} + +/** + * The filename the capture is keyed by. + * + * The two sides reach this value by different routes, which is the one part of this + * mechanism that is assumed rather than enforced. The hook is handed `session_id` by the + * host on stdin; this process reads GLEAN_SESSION_ID, which start.mjs sets from the host's + * own variable (CLAUDE_CODE_SESSION_ID, or CODEX_THREAD_ID on Codex). Nothing makes the + * host's hook payload and the host's environment variable the same identifier -- they just + * are, on Claude Code, which the HITL permission-mode marker has relied on in production + * for as long as it has existed. + * + * Confirmed equal on Claude Code, by observation rather than by contract: a capture written + * by the hook appeared under exactly the key this side computed. + * + * Codex is the open case: its variable is named for a *thread* while its hook payload field + * is named for a session, and the two have not been confirmed equal. If they differ, the + * capture lands under a key nothing reads, so `describeMiss` below reports what was looked + * for and whether anything else is present. + */ +function sessionKey(): string { + return resolveSessionId() + .replace(/[^a-zA-Z0-9_-]/g, "-") + .slice(0, 64); +} + +/** + * Why there is no capture for this session, in enough detail to narrow the cause. + * + * Counting the captures under other keys separates "nothing has ever captured here" from + * "something captured, but not under my key". The first rules out the hook running at all + * on this host, which is the question worth answering for a host whose hook support is + * unconfirmed. + * + * It does NOT prove a key mismatch on its own. A second concurrent session whose own + * capture has not landed yet looks exactly the same, and hosts do run several plugin + * processes at once -- observed with three live at once on Claude Code. The count is + * conclusive only when one session is running, which is the situation someone diagnosing + * this would arrange deliberately. The keys themselves are not logged, only how many. + */ +function describeMiss(code: string | undefined): InventoryDiagnostic { + const diagnostic: InventoryDiagnostic = { + detail: code === "ENOENT" ? "no capture file" : `unreadable (${code ?? "unknown"})`, + sessionKey: sessionKey(), + }; + try { + diagnostic.otherCaptures = fs + .readdirSync(inventoryDir()) + .filter((name) => name.endsWith(".json")).length; + } catch { + // No directory at all, which is the ordinary case before any capture has happened. + } + return diagnostic; +} + +function unavailable( + reason: InventoryUnavailableReason, + diagnostic: InventoryDiagnostic, +): ConfiguredServers { + lastDiagnostic = diagnostic; + return { source: "unavailable", reason }; +} + +/** + * The inventory captured for this session, or `unavailable` with a reason. + * + * `unavailable` is a legitimate answer with a defined meaning -- it carries no servers + * and implies nothing about the user's setup -- so every failure path returns it rather + * than throwing or inventing an empty list. There are more of those paths than there are + * error cases: a host with no MCP CLI never captures at all, and even on a supported host + * the first `tools/list` typically precedes the capture, because SessionStart hooks fire + * before servers finish connecting. + * + * The reason exists because those outcomes were otherwise indistinguishable, on the wire + * and in the log alike. A fleet reporting `unavailable` for most sessions is either + * working exactly as designed or completely broken, and nothing said which. + * + * Validated rather than trusted. The file is written by a separate process that may be + * from a different plugin version, so a shape mismatch has to degrade to `unavailable` + * instead of putting unchecked values into the negotiation payload. Partial validity is + * not a state: one bad entry discards the batch, matching the all-or-nothing rule the + * hook applies to CLI output, because a truncated inventory is indistinguishable from a + * user who genuinely has fewer servers. + */ +export function loadCachedInventory(): ConfiguredServers { + let raw: string; + try { + raw = fs.readFileSync(inventoryCachePath(), "utf-8"); + } catch (err) { + // Overwhelmingly ENOENT: no capture yet, which is the ordinary state early in a + // session and the permanent one on a host that runs no hooks. + const code = (err as { code?: string })?.code; + return unavailable("capture-pending", describeMiss(code)); + } + + let parsed: InventoryCacheFile; + try { + parsed = JSON.parse(raw) as InventoryCacheFile; + } catch { + return unavailable("capture-invalid", { detail: "not JSON", bytes: raw.length }); + } + + // The hook writes a negative marker when it ran and had nothing, which is what + // separates "the capture failed" from "the capture has not happened yet". + if (parsed?.source === "unavailable") { + if (typeof parsed.reason === "string" && HOOK_REASONS.has(parsed.reason)) { + return unavailable(parsed.reason as InventoryUnavailableReason, { + detail: "hook reported no inventory", + }); + } + return unavailable("capture-invalid", { + detail: "hook reason not recognized", + badField: "reason", + badType: typeof parsed.reason, + }); + } + + if (parsed?.source !== "host-cli") { + return unavailable("capture-invalid", { + detail: "source not recognized", + badField: "source", + badType: typeof parsed?.source, + }); + } + if (!Array.isArray(parsed.servers)) { + return unavailable("capture-invalid", { + detail: "servers is not an array", + badField: "servers", + badType: typeof parsed.servers, + }); + } + + const servers: ConfiguredServer[] = []; + for (const [index, entry] of parsed.servers.entries()) { + const outcome = validateServer(entry); + if ("bad" in outcome) { + return unavailable("capture-invalid", { + detail: "server entry rejected", + entries: parsed.servers.length, + badIndex: index, + ...outcome.bad, + }); + } + servers.push(outcome.server); + } + + const withheld = + typeof parsed.withheld === "number" && + Number.isInteger(parsed.withheld) && + parsed.withheld >= 0 + ? parsed.withheld + : undefined; + + lastDiagnostic = undefined; + return withheld === undefined + ? { source: "host-cli", servers } + : { source: "host-cli", servers, withheld }; +} + +// Enum-shaped values only. authStatus is the one field where the offending VALUE earns +// its place -- "we saw `connected`, we expect `authenticated`" names a version skew +// outright -- but it is still untrusted text from a file, so it passes only if it could +// not be a token, a hostname, or a path. +const ENUM_SHAPED = /^[a-z_-]{1,32}$/; + +type ServerOutcome = + | { server: ConfiguredServer } + | { bad: { badField: string; badType: string; badValue?: string } }; + +function validateServer(entry: unknown): ServerOutcome { + if (!entry || typeof entry !== "object") { + return { bad: { badField: "(entry)", badType: typeof entry } }; + } + const { name, url, authStatus } = entry as Record; + if (typeof name !== "string" || !name) { + // The name itself is never logged: in a rejected file it may be a third party's. + return { bad: { badField: "name", badType: typeof name } }; + } + if (typeof authStatus !== "string" || !AUTH_STATUSES.has(authStatus)) { + return { + bad: { + badField: "authStatus", + badType: typeof authStatus, + badValue: + typeof authStatus === "string" && ENUM_SHAPED.test(authStatus) + ? authStatus + : undefined, + }, + }; + } + if (url !== undefined && typeof url !== "string") { + return { bad: { badField: "url", badType: typeof url } }; + } + // Rebuilt field by field rather than spread, so a key the hook adds later -- or one + // an older cache file still carries -- cannot reach the payload unreviewed. + return { + server: + url === undefined + ? { name, authStatus: authStatus as AuthStatus } + : { name, url, authStatus: authStatus as AuthStatus }, + }; +} diff --git a/shared/glean/mcp/src/policy/session.ts b/shared/glean/mcp/src/policy/session.ts index a88fb45..23e862b 100644 --- a/shared/glean/mcp/src/policy/session.ts +++ b/shared/glean/mcp/src/policy/session.ts @@ -4,6 +4,7 @@ import { hostIdentityFromHandshake, buildNegotiationRequest, supportedFeatures } import { classifyResult, metaFor } from "./negotiate.js"; import { evaluate } from "./evaluate.js"; import { loadCachedPolicy, savePolicy } from "./cache.js"; +import { lastInventoryDiagnostic } from "./inventory-cache.js"; import { ProtocolVersionObserver } from "./protocol-version.js"; import type { Decision, NegotiationRequest, PolicyResponse } from "./types.js"; @@ -64,9 +65,44 @@ export function negotiationRequest(): NegotiationRequest { protocolVersion.version, ); lastRequest = buildNegotiationRequest(host); + reportInventoryGap(lastRequest.configuredServers); return lastRequest; } +// The last inventory outcome reported, so a stable state is not logged on every request. +// `_meta` rides every remote call, so an unconditional line here would be one per call +// for the life of the process. +let lastInventoryReason: string | undefined; + +/** + * Say why there is no inventory, when there is none. + * + * The coarse reason travels to the remote; the fine-grained detail is local-only and + * stays here. Without both, every way of arriving at `unavailable` looked identical -- + * hook never fired, CLI missing, output unparsed, capture rejected -- so a plugin whose + * inventory silently never worked was indistinguishable from one working exactly as + * designed on a host with no CLI. + * + * Logged when the outcome CHANGES, which for the ordinary session means twice: once for + * `capture-pending` on the early requests, then once when the capture lands. + */ +function reportInventoryGap(inventory: NegotiationRequest["configuredServers"]): void { + const reason = inventory.source === "host-cli" ? "resolved" : inventory.reason; + if (reason === lastInventoryReason) return; + lastInventoryReason = reason; + if (inventory.source === "host-cli") { + logLine("inventory.resolved", { + servers: inventory.servers?.length ?? 0, + withheld: inventory.withheld, + }); + return; + } + logLine("inventory.unavailable", { + reason, + ...(lastInventoryDiagnostic() ?? {}), + }); +} + /** The `_meta` envelope to attach to an outgoing remote request. */ export function negotiationMeta(): { _meta: Record } { return metaFor(negotiationRequest()); diff --git a/shared/glean/mcp/src/policy/types.ts b/shared/glean/mcp/src/policy/types.ts index d06ad32..32724fe 100644 --- a/shared/glean/mcp/src/policy/types.ts +++ b/shared/glean/mcp/src/policy/types.ts @@ -17,6 +17,38 @@ export type HostSource = "handshake" | "env" | "unknown"; // fewer servers. export type InventorySource = "host-cli" | "unavailable"; +/** + * Why no inventory was obtained. Sent so the remote can tell an expected absence from a + * broken one -- without it, a fleet reporting 60% `unavailable` cannot be read at all. + * + * Deliberately COARSE. There are far more internal failure branches than these, and + * mirroring them here would freeze the implementation's shape into the wire contract; the + * local log carries the fine-grained detail instead. Deliberately a CLOSED SET, never + * free text: a reason carrying an exec error would ship an absolute binary path, which on + * a normal install contains the user's name. + * + * There is no `host-unsupported`. On a host with no MCP CLI the hook never runs at all, + * so nothing is there to write a marker, and `host.id` already identifies the host -- the + * remote joins the two itself. Encoding the mapping here would put host-specific + * knowledge in a plugin that otherwise has none. + */ +export type InventoryUnavailableReason = + /** No capture on disk. The ordinary state of a session's first tools/list, since + * SessionStart hooks fire before servers finish connecting -- and the permanent state + * on a host that runs no hooks. */ + | "capture-pending" + /** The host CLI could not be located, could not be run, or timed out. */ + | "cli-unavailable" + /** The CLI ran and its output could not be used, so it was discarded whole. Covers a + * parse failure and a well-formed response of an unexpected shape alike: which of the + * two it was matters to us and not to the remote, so the distinction lives in the log + * rather than in this name. */ + | "cli-output-invalid" + /** A capture exists but failed validation. Unlike `cli-output-invalid` the bad data is + * OURS, not the host's -- version skew between the hook and this build, corruption, or + * something else writing to the path -- so the fix is different in kind. */ + | "capture-invalid"; + export type AuthStatus = "authenticated" | "unauthenticated" | "unknown"; export interface ConfiguredServer { @@ -28,6 +60,18 @@ export interface ConfiguredServer { export interface ConfiguredServers { source: InventorySource; servers?: ConfiguredServer[]; + /** + * How many servers were found but withheld by the Glean-only filter. + * + * Reported as a bare count because the excluded entries are exactly the ones that + * must not be named -- a third party's server name or hostname is the disclosure the + * filter exists to prevent. The count still tells the remote that filtering happened, + * which is the difference between "this user has one Glean server" and "we could only + * confirm one of the servers this user has". + */ + withheld?: number; + /** Present only when `source` is `unavailable`. */ + reason?: InventoryUnavailableReason; } /** The host block of the request, as observed from the MCP handshake. */ diff --git a/shared/glean/mcp/src/remote-tools-cache-store.ts b/shared/glean/mcp/src/remote-tools-cache-store.ts index 4eceac0..8c0b568 100644 --- a/shared/glean/mcp/src/remote-tools-cache-store.ts +++ b/shared/glean/mcp/src/remote-tools-cache-store.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { homedir } from "node:os"; +import { serverDataDir } from "./data-dir.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { writeFileAtomicSync } from "./atomic-write.js"; @@ -8,12 +8,8 @@ const CACHE_FILENAME = "remote-tools-cache.json"; const DIR_MODE = 0o700; const FILE_MODE = 0o600; -function resolveCacheDir(): string { - return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); -} - function cacheFile(): string { - return path.join(resolveCacheDir(), CACHE_FILENAME); + return path.join(serverDataDir(), CACHE_FILENAME); } interface ToolsCacheEntry { diff --git a/shared/glean/mcp/src/token-store.ts b/shared/glean/mcp/src/token-store.ts index e41c2fa..1cad673 100644 --- a/shared/glean/mcp/src/token-store.ts +++ b/shared/glean/mcp/src/token-store.ts @@ -1,17 +1,13 @@ import fs from "node:fs"; import path from "node:path"; -import { homedir } from "node:os"; +import { serverDataDir } from "./data-dir.js"; const CREDENTIALS_FILENAME = "mcp-credentials.json"; const DIR_MODE = 0o700; const FILE_MODE = 0o600; -function resolveCredentialsDir(): string { - return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); -} - function credentialsFile(): string { - return path.join(resolveCredentialsDir(), CREDENTIALS_FILENAME); + return path.join(serverDataDir(), CREDENTIALS_FILENAME); } interface StoredCredentials { diff --git a/shared/glean/mcp/src/tools/run-tool.ts b/shared/glean/mcp/src/tools/run-tool.ts index 7c8d2d7..2436b6a 100644 --- a/shared/glean/mcp/src/tools/run-tool.ts +++ b/shared/glean/mcp/src/tools/run-tool.ts @@ -9,6 +9,7 @@ import { callRemoteTool } from "../remote-client.js"; import { FILE_ARGS_DISABLED_TEXT } from "../policy/enforce.js"; import { buildCompactArgs, writeApprovalArgsFile } from "./approval-args.js"; import { resolveSessionId } from "../session-id.js"; +import { hostSharedDataDir } from "../data-dir.js"; const DEFAULT_FILE_ARG_MAX_BYTES = 5 * 1024 * 1024; @@ -241,17 +242,13 @@ function primeElicitationCancellation(mcpServer: Server): void { // Path to the per-session permission-mode marker the PreToolUse hook writes // immediately before each run_tool call (see hooks/auto-approve-run-tool.mjs). -// This resolution MUST match the hook's exactly. The hook cannot see the -// server-only PLUGIN_DATA_DIR that start.sh derives, so both sides key off -// CLAUDE_PLUGIN_DATA (falling back to ~/.glean) — the one anchor available to -// both processes. Under start.sh, PLUGIN_DATA_DIR resolves to this same path. +// The directory has to be the one the HOOK can compute, not the one this process +// would prefer -- see hostSharedDataDir() in ../data-dir.ts. function permissionModeMarkerPath(): string { - const base = - process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".glean"); const sessionId = resolveSessionId() .replace(/[^a-zA-Z0-9_-]/g, "-") .slice(0, 64); - return path.join(base, "glean-hitl-mode", `${sessionId}.json`); + return path.join(hostSharedDataDir(), "glean-hitl-mode", `${sessionId}.json`); } // Claude Code's live permission mode for THIS session, as captured by the hook diff --git a/shared/glean/mcp/src/url-config-store.ts b/shared/glean/mcp/src/url-config-store.ts index df46d7e..d2f24aa 100644 --- a/shared/glean/mcp/src/url-config-store.ts +++ b/shared/glean/mcp/src/url-config-store.ts @@ -1,17 +1,13 @@ import fs from "node:fs"; import path from "node:path"; -import { homedir } from "node:os"; +import { serverDataDir } from "./data-dir.js"; const CONFIG_FILENAME = "mcp-server-url.json"; const DIR_MODE = 0o700; const FILE_MODE = 0o600; -function resolveConfigDir(): string { - return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); -} - function configFile(): string { - return path.join(resolveConfigDir(), CONFIG_FILENAME); + return path.join(serverDataDir(), CONFIG_FILENAME); } interface StoredConfig { diff --git a/shared/glean/mcp/tests/capture-inventory-hook.test.ts b/shared/glean/mcp/tests/capture-inventory-hook.test.ts new file mode 100644 index 0000000..e43755f --- /dev/null +++ b/shared/glean/mcp/tests/capture-inventory-hook.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it, vi } from "vitest"; +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +// Every spec here spawns the real hook against a stubbed host CLI, and the stub is a +// `#!/bin/sh` script, so the suite is POSIX-only. The hook itself is cross-platform -- +// claudeCandidates() already handles the Windows `.cmd` shim, and the shipped tree carries +// no shell scripts by CI guard -- it is only the test's stub that cannot be made portable +// without inventing an executable Node shim. CI runs ubuntu, so this skips only for +// developers on Windows, where a failure would otherwise look like a real defect. +const WINDOWS = process.platform === "win32"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const HOOK_SOURCE = path.resolve( + here, + "../../../../overrides/claude/glean/hooks/capture-inventory.mjs", +); + +interface Run { + cache: { + source?: string; + servers?: { name: string; url?: string; authStatus: string }[]; + withheld?: number; + cwd?: string; + reason?: string; + } | null; + raw: string | null; + projectDir: string; +} + +interface Options { + /** Text the stubbed CLI prints. Omit to stub a CLI that cannot run at all. */ + cliOutput?: string; + /** + * Stored server URL, as `setup` would have written it. The hook no longer reads it -- + * that is the point of the test that sets it, which fails the moment someone + * reintroduces configured-host matching. + */ + configuredUrl?: string; + sessionId?: string | null; +} + +/** + * Run the real hook against a stubbed host CLI. + * + * The CLI is stubbed through CLAUDE_CODE_EXECPATH / CODEX_EXECPATH, which the hook's + * candidate lists try first, and PATH is emptied so a bare-name lookup cannot reach a + * real CLI installed on the machine running the tests. That matters more than it looks: + * `claude mcp list` output is not even stable between consecutive invocations from one + * directory -- a server present in one run was absent from the next -- so a test that + * touched the real CLI would be a test that fails on someone else's laptop. + */ +async function runHook(options: Options): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "capture-inv-")); + const dataDir = path.join(root, "data"); + await fs.mkdir(dataDir, { recursive: true }); + + const hookDir = path.join(root, "hooks"); + await fs.mkdir(hookDir, { recursive: true }); + const hook = path.join(hookDir, "capture-inventory.mjs"); + await fs.copyFile(HOOK_SOURCE, hook); + + if (options.configuredUrl) { + await fs.writeFile( + path.join(dataDir, "mcp-server-url.json"), + JSON.stringify({ serverUrl: options.configuredUrl }), + ); + } + + const fakeCli = path.join(root, "fake-cli"); + if (options.cliOutput === undefined) { + // Exists but is not executable, so every candidate fails and the hook must conclude + // `unavailable` rather than writing something. + await fs.writeFile(fakeCli, "not executable", { mode: 0o600 }); + } else { + // /bin/sh by absolute path in the shebang, so an emptied PATH cannot break it. + const payload = options.cliOutput.replaceAll("'", "'\"'\"'"); + await fs.writeFile(fakeCli, `#!/bin/sh +printf '%s' '${payload}' +`, { mode: 0o755 }); + } + + const sessionId = + options.sessionId === undefined ? "sess-1" : options.sessionId; + // A real directory: the hook hands this to execFile as the CLI's cwd, and a path that + // does not exist fails the spawn outright (which is itself a correct degrade to + // `unavailable`, but not what these tests are checking). + const projectDir = path.join(root, "project"); + await fs.mkdir(projectDir, { recursive: true }); + const input: Record = { cwd: projectDir }; + if (sessionId !== null) input.session_id = sessionId; + + await new Promise((resolve) => { + const child = spawn( + process.execPath, + [hook], + { + env: { + CLAUDE_PLUGIN_DATA: dataDir, + CLAUDE_CODE_EXECPATH: fakeCli, + CLAUDE_CODE_EXECPATH: fakeCli, + PATH: path.join(root, "no-such-bin"), + HOME: root, + }, + stdio: ["pipe", "ignore", "ignore"], + }, + ); + child.stdin.end(JSON.stringify(input)); + child.on("close", () => resolve()); + }); + + const file = path.join(dataDir, "inventory", `${sessionId}.json`); + let raw: string | null = null; + try { + raw = await fs.readFile(file, "utf-8"); + } catch { + raw = null; + } + await fs.rm(root, { recursive: true, force: true }); + return { cache: raw === null ? null : JSON.parse(raw), raw, projectDir }; +} + +// Recorded from a real `claude mcp list`, including the preamble and blank line. Three +// servers: our own plugin and an unrelated tool, both stdio and both withheld, plus one +// Glean remote that is reported. +const CLAUDE_REAL = [ + "Checking MCP server health\u2026", + "", + "plugin:glean-vnext:glean: node /Users/someone/.claude/plugins/cache/glean-plugins-vnext/glean-vnext/0.2.43/start.mjs - \u2714 Connected", + "glean_default: https://scio-prod-be.glean.com/mcp/default (HTTP) - \u2714 Connected", + "chrome-devtools: npx -y chrome-devtools-mcp@latest --autoConnect --channel=beta - \u2718 Failed to connect \u2014 -32000: MCP error -32000: Connection closed", +].join("\n"); + +describe.skipIf(WINDOWS)("claude mcp list capture", () => { + // Only the remote Glean server is reported. Both stdio entries are withheld, including + // this plugin's own: a stdio server exposes no URL and so can never be confirmed, and + // reporting ours would add nothing the request's own plugin block does not already say. + it("reports the Glean remote and withholds every stdio server", async () => { + const { cache } = await runHook({ cliOutput: CLAUDE_REAL }); + + expect(cache).toMatchObject({ + source: "host-cli", + servers: [ + { + name: "glean_default", + url: "https://scio-prod-be.glean.com/mcp/default", + authStatus: "authenticated", + }, + ], + withheld: 2, + }); + }); + + // A stdio entry's target is a launch command, and it is dropped at parse time rather + // than carried and filtered later: a path discloses filesystem layout for no policy + // benefit, and the surest way not to leak it is never to hold it. + it("never reports a launch path", async () => { + const { raw } = await runHook({ cliOutput: CLAUDE_REAL }); + expect(raw).not.toContain("start.mjs"); + expect(raw).not.toContain("/Users/someone"); + }); + + it("treats pending approval as unknown, not unauthenticated", async () => { + const { cache } = await runHook({ + cliOutput: + "glean_default: https://acme-be.glean.com/mcp (HTTP) - \u23f8 Pending approval (run `claude` to approve)", + }); + + // Never connected, so nothing was learned about credentials. Calling it + // unauthenticated would be a wrong answer rather than a cautious one. + expect(cache?.servers).toEqual([ + { name: "glean_default", url: "https://acme-be.glean.com/mcp", authStatus: "unknown" }, + ]); + }); + + it("maps an explicit authentication prompt to unauthenticated", async () => { + const { cache } = await runHook({ + cliOutput: + "glean_default: https://acme-be.glean.com/mcp (HTTP) - needs authentication", + }); + expect(cache?.servers?.[0].authStatus).toBe("unauthenticated"); + }); + + it("maps a connection failure to unknown", async () => { + const { cache } = await runHook({ + cliOutput: + "glean_default: https://acme-be.glean.com/mcp (HTTP) - \u2718 Failed to connect \u2014 -32000: MCP error -32000: Connection closed", + }); + expect(cache?.servers?.[0].authStatus).toBe("unknown"); + }); + + // All-or-nothing. One unrecognized line means the format shifted, and a truncated + // inventory is indistinguishable from a user who genuinely has fewer servers. + it("discards the whole inventory when any line does not parse", async () => { + const { cache } = await runHook({ + cliOutput: [ + "glean_default: https://acme-be.glean.com/mcp (HTTP) - \u2714 Connected", + "a brand new output format nobody taught us", + ].join("\n"), + }); + // The one recognized line is discarded with the rest, and the marker names why so a + // format shift is diagnosable rather than merely absent. + expect(cache?.reason).toBe("cli-output-invalid"); + expect(cache?.servers).toBeUndefined(); + }); + + it("distinguishes a host with no servers from a host it could not ask", async () => { + const { cache } = await runHook({ + cliOutput: "No MCP servers configured. Use `claude mcp add` to add one.", + }); + expect(cache).toMatchObject({ source: "host-cli", servers: [], withheld: 0 }); + }); + + // A marker rather than no file: "the hook never fired" and "the hook fired and could + // not find the CLI" need different fixes and used to look identical. + it("records that the CLI could not be run", async () => { + const { cache, raw } = await runHook({}); + expect(cache?.reason).toBe("cli-unavailable"); + // Never the exec error: it carries an absolute binary path, and on a normal install + // that path contains the user's name. Nor the working directory. + expect(raw).not.toContain("ENOENT"); + expect(raw).not.toContain("EACCES"); + expect(raw).not.toContain("fake-cli"); + expect(raw).not.toContain(os.tmpdir()); + }); + + // The working directory is passed to the CLI, because the output depends on it, and then + // NOT recorded. A path is filesystem layout: /Users//... carries a username and + // project directory names carry customers'. Same reason a stdio server's launch path is + // used for identification and discarded. + it("writes no filesystem path of any kind", async () => { + const { cache, raw, projectDir } = await runHook({ + cliOutput: CLAUDE_REAL, + }); + + expect(cache?.cwd).toBeUndefined(); + expect(raw).not.toContain(projectDir); + expect(raw).not.toContain(os.homedir()); + expect(raw).not.toContain(os.tmpdir()); + }); +}); + +// The control that keeps a customer's estate out of the payload. Its failure mode is +// disclosure, so it gets the most cases. +// +// One risk left with the Codex parser: `codex mcp list --json` emits transport.env, +// http_headers, env_http_headers and bearer_token_env_var verbatim, so entries had to be +// rebuilt field by field rather than trusted. `claude mcp list` prints only +// `name: target - status`, so there is no credential-bearing field to leak in the first +// place. Dropping Codex removed that whole class of exposure from the shipping path; the +// tests for it live on mohit/inventory-codex-followup with the parser. +describe.skipIf(WINDOWS)("the Glean-only filter", () => { + const remote = (name: string, url: string) => + `${name}: ${url} (HTTP) - \u2714 Connected`; + + it("admits Glean's own domain", async () => { + const { cache } = await runHook({ + cliOutput: remote("glean_default", "https://anything-be.glean.com/mcp"), + }); + expect(cache?.servers).toHaveLength(1); + }); + + // A white-labeled deployment on the customer's own domain is NOT admitted, even though + // the plugin could read its configured URL and match on it. That would also admit + // anything else fronted off the same host under a different path, and a corporate + // gateway multiplexing several MCP servers is entirely ordinary. Under-reporting is the + // side to fail on. + it("does not admit a customer domain, even the configured one", async () => { + const { cache } = await runHook({ + configuredUrl: "https://mcp.acme.com/glean/mcp", + cliOutput: remote("white-labeled", "https://mcp.acme.com/glean/mcp"), + }); + expect(cache).toMatchObject({ servers: [], withheld: 1 }); + }); + + it("is not fooled by a lookalike domain", async () => { + const { cache } = await runHook({ + cliOutput: remote("phish", "https://glean.com.evil.example/mcp"), + }); + expect(cache).toMatchObject({ servers: [], withheld: 1 }); + }); + + // Query strings on MCP URLs carry credentials and search-config parameters in the wild, + // so a URL is reduced to origin plus path. This is the one scrubbing rule that still + // applies with Codex gone, because Claude prints URLs verbatim. + it("drops a query string that may carry a token", async () => { + const { cache, raw } = await runHook({ + cliOutput: remote("glean_default", "https://acme-be.glean.com/mcp?token=must-not-appear"), + }); + expect(raw).not.toContain("must-not-appear"); + expect(cache?.servers?.[0].url).toBe("https://acme-be.glean.com/mcp"); + }); + + // The name is never consulted, only the URL. Otherwise a server label containing "glean" + // would be as good as proof. + it("withholds a Glean-sounding stdio server", async () => { + const { cache } = await runHook({ + cliOutput: "glean-totally-legit: node /Users/someone/gleanwork/server.mjs - \u2714 Connected", + }); + expect(cache).toMatchObject({ servers: [], withheld: 1 }); + }); +}); + +// The one piece of duplication that cannot be removed. This hook is unbundled ESM the host +// spawns directly, while the read side is compiled into dist/, so the two independently +// compute the same path from the same environment variable. A divergence would be silent -- +// the hook writing somewhere nothing ever looks -- so rather than a comment asserting they +// match, this runs the real hook and reads the result back through the real read path. +describe.skipIf(WINDOWS)("the hook and the server agree on where the capture lives", () => { + it("finds a capture the hook actually wrote", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "inv-agree-")); + const hookDir = path.join(root, "hooks"); + await fs.mkdir(hookDir, { recursive: true }); + const hook = path.join(hookDir, "capture-inventory.mjs"); + await fs.copyFile(HOOK_SOURCE, hook); + + const fakeCli = path.join(root, "fake-claude"); + const payload = "glean_default: https://acme-be.glean.com/mcp (HTTP) - ✔ Connected"; + await fs.writeFile( + fakeCli, + `#!/bin/sh\nprintf '%s' '${payload}'\n`, + { mode: 0o755 }, + ); + + // Only CLAUDE_PLUGIN_DATA is set, because that is the only variable the hook can see. + await new Promise((resolve) => { + const child = spawn(process.execPath, [hook], { + env: { + CLAUDE_PLUGIN_DATA: root, + CLAUDE_CODE_EXECPATH: fakeCli, + PATH: path.join(root, "no-such-bin"), + HOME: root, + }, + stdio: ["pipe", "ignore", "ignore"], + }); + child.stdin.end(JSON.stringify({ session_id: "agree-1", cwd: root })); + child.on("close", () => resolve()); + }); + + // Read back through the module that resolves its own path, with no shared constant + // between the two sides. + vi.resetModules(); + vi.stubEnv("CLAUDE_PLUGIN_DATA", root); + vi.stubEnv("GLEAN_SESSION_ID", "agree-1"); + const { loadCachedInventory } = await import("../src/policy/inventory-cache.js"); + + expect(loadCachedInventory()).toMatchObject({ + source: "host-cli", + servers: [{ name: "glean_default", url: "https://acme-be.glean.com/mcp" }], + }); + + vi.unstubAllEnvs(); + await fs.rm(root, { recursive: true, force: true }); + }); +}); + +// The capture holds server names and URLs, so its permissions are part of the filter's +// job, not housekeeping. They also degrade silently: `mode` on writeFileSync and mkdirSync +// applies only on creation and is masked by umask, so a wrong umask or a pre-existing +// directory loosens them with nothing to notice. +describe.skipIf(WINDOWS)("how the capture is written", () => { + it("writes 0600 into a 0700 directory and leaves no temp behind", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "inv-perm-")); + const dataDir = path.join(root, "data"); + // Pre-created wide open, so a mkdir that only passes `mode` would not tighten it. + await fs.mkdir(path.join(dataDir, "inventory"), { recursive: true, mode: 0o777 }); + await fs.chmod(path.join(dataDir, "inventory"), 0o777); + + const hook = path.join(root, "capture-inventory.mjs"); + await fs.copyFile(HOOK_SOURCE, hook); + const fakeCli = path.join(root, "fake-claude"); + await fs.writeFile( + fakeCli, + `#!/bin/sh\nprintf '%s' 'glean_default: https://acme-be.glean.com/mcp (HTTP) - ✔ Connected'\n`, + { mode: 0o755 }, + ); + + await new Promise((resolve) => { + const child = spawn(process.execPath, [hook], { + env: { + CLAUDE_PLUGIN_DATA: dataDir, + CLAUDE_CODE_EXECPATH: fakeCli, + PATH: path.join(root, "no-such-bin"), + HOME: root, + }, + stdio: ["pipe", "ignore", "ignore"], + }); + child.stdin.end(JSON.stringify({ session_id: "perm-1", cwd: root })); + child.on("close", () => resolve()); + }); + + const invDir = path.join(dataDir, "inventory"); + const fileMode = (await fs.stat(path.join(invDir, "perm-1.json"))).mode & 0o777; + const dirMode = (await fs.stat(invDir)).mode & 0o777; + expect(fileMode).toBe(0o600); + expect(dirMode).toBe(0o700); + + // Nothing else in the directory: no stray file, and no temp, since the write is a + // plain one -- a per-session filename with a single writer has no race to guard. + expect(await fs.readdir(invDir)).toEqual(["perm-1.json"]); + + await fs.rm(root, { recursive: true, force: true }); + }); +}); + +describe.skipIf(WINDOWS)("hook preconditions", () => { + it("writes nothing without a session id to key by", async () => { + const { cache } = await runHook({ + cliOutput: CLAUDE_REAL, + sessionId: null, + }); + expect(cache).toBeNull(); + }); + +}); diff --git a/shared/glean/mcp/tests/inventory-cache.test.ts b/shared/glean/mcp/tests/inventory-cache.test.ts new file mode 100644 index 0000000..8cbb21b --- /dev/null +++ b/shared/glean/mcp/tests/inventory-cache.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Applied to the whole file, deliberately. `claude mcp list` health-checks every server, +// which SPAWNS each stdio one -- including this plugin, whose spawned copy goes on to +// serve a full tools/list with a live remote fetch. So a shell-out from the read path +// would recurse without bound, one process and one backend call per level. Every entry +// point is replaced with one that records and throws, so any attempt is both counted and +// fatal rather than quietly retried. +const { spawnAttempts } = vi.hoisted(() => ({ spawnAttempts: [] as string[] })); +vi.mock("node:child_process", () => { + const refuse = + (name: string) => + (..._args: unknown[]) => { + spawnAttempts.push(name); + throw new Error(`inventory-cache must never spawn a process (tried ${name})`); + }; + return { + spawn: refuse("spawn"), + spawnSync: refuse("spawnSync"), + exec: refuse("exec"), + execFile: refuse("execFile"), + execSync: refuse("execSync"), + execFileSync: refuse("execFileSync"), + default: {}, + }; +}); + +async function freshCache(dir: string, sessionId = "sess-1") { + vi.resetModules(); + vi.stubEnv("CLAUDE_PLUGIN_DATA", dir); + vi.stubEnv("GLEAN_SESSION_ID", sessionId); + return await import("../src/policy/inventory-cache.js"); +} + +function seed(dir: string, sessionId: string, body: unknown) { + const target = path.join(dir, "inventory"); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync( + path.join(target, `${sessionId}.json`), + typeof body === "string" ? body : JSON.stringify(body), + ); +} + +const VALID = { + source: "host-cli", + servers: [ + { name: "glean_default", url: "https://acme-be.glean.com/mcp/default", authStatus: "authenticated" }, + { name: "glean-local", authStatus: "unknown" }, + ], + withheld: 2, + capturedAt: "2026-08-20T00:00:00Z", +}; + +describe("loadCachedInventory", () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "inv-cache-")); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it("reads what the hook wrote", async () => { + seed(dir, "sess-1", VALID); + const { loadCachedInventory } = await freshCache(dir); + + expect(loadCachedInventory()).toEqual({ + source: "host-cli", + servers: [ + { name: "glean_default", url: "https://acme-be.glean.com/mcp/default", authStatus: "authenticated" }, + { name: "glean-local", authStatus: "unknown" }, + ], + withheld: 2, + }); + }); + + // Only the fields the contract defines reach the payload. capturedAt is local + // bookkeeping; `cwd` is a path an earlier build recorded and this one does not, so a + // file left by that build still carries it. It has to be tolerated and dropped rather + // than rejected -- refusing a removed field would turn it into an outage on upgrade -- + // and dropped rather than forwarded, because a path is filesystem layout. + it("passes through only the contract's fields", async () => { + seed(dir, "sess-1", { ...VALID, cwd: "/Users/someone/acme-migration" }); + const { loadCachedInventory } = await freshCache(dir); + + const result = loadCachedInventory(); + expect(Object.keys(result).sort()).toEqual(["servers", "source", "withheld"]); + expect(JSON.stringify(result)).not.toContain("acme-migration"); + }); + + // The one case asserting the exact shape, so a stray `servers` or `withheld` alongside + // an unavailable result would be caught somewhere. The rest name only the code. + it("is unavailable when no capture has happened", async () => { + const { loadCachedInventory } = await freshCache(dir); + expect(loadCachedInventory()).toEqual({ + source: "unavailable", + reason: "capture-pending", + }); + }); + + // The first tools/list of a session normally lands here: SessionStart hooks fire + // before servers finish connecting, and the Claude capture takes seconds. + it("is unavailable for a session other than the one captured", async () => { + seed(dir, "someone-elses-session", VALID); + const { loadCachedInventory } = await freshCache(dir, "sess-1"); + // Indistinguishable from never having captured, and correctly so: this session has + // no capture of its own, whatever other sessions did. + expect(loadCachedInventory().reason).toBe("capture-pending"); + }); + + // The one failure this mechanism cannot rule out by construction. The hook is handed + // `session_id` by the host on stdin; this side reads GLEAN_SESSION_ID, which the launcher + // sets from the host's own variable. Nothing guarantees those are the same identifier -- + // they are on Claude Code, where the HITL marker has depended on it in production, but + // Codex names its variable for a thread and its hook field for a session, and the two are + // unconfirmed. If they ever differ the capture lands under a key nothing reads, so the + // miss has to be distinguishable from "the hook has not run yet". + it("distinguishes a capture under another key from none at all", async () => { + seed(dir, "the-hook-used-this-key", VALID); + const { loadCachedInventory, lastInventoryDiagnostic } = await freshCache( + dir, + "the-server-wants-this-key", + ); + + expect(loadCachedInventory().reason).toBe("capture-pending"); + expect(lastInventoryDiagnostic()).toEqual({ + detail: "no capture file", + sessionKey: "the-server-wants-this-key", + // Narrows the cause without proving it. A second concurrent session whose own capture + // has not landed yet produces the same count -- observed on Claude Code, which ran + // three plugin processes at once. What it does rule out is the hook never having run + // on this host at all, which is the question for a host whose support is unconfirmed. + otherCaptures: 1, + }); + }); + + it("reports no other captures when the hook simply has not run", async () => { + const { loadCachedInventory, lastInventoryDiagnostic } = await freshCache(dir); + loadCachedInventory(); + + // Absent rather than zero: the directory does not exist, so there was nothing to count. + expect(lastInventoryDiagnostic()?.otherCaptures).toBeUndefined(); + }); + + it("is unavailable when the file is not JSON", async () => { + seed(dir, "sess-1", "{not json"); + const { loadCachedInventory } = await freshCache(dir); + expect(loadCachedInventory().reason).toBe("capture-invalid"); + }); + + it("is unavailable when source is not host-cli", async () => { + seed(dir, "sess-1", { ...VALID, source: "files" }); + const { loadCachedInventory } = await freshCache(dir); + expect(loadCachedInventory().reason).toBe("capture-invalid"); + }); + + // All-or-nothing, the same rule the hook applies to CLI output: a truncated inventory + // is indistinguishable from a user who genuinely has fewer servers, so one bad entry + // discards the batch rather than yielding a shorter list. + it("discards the whole batch when one entry is invalid", async () => { + seed(dir, "sess-1", { + ...VALID, + servers: [VALID.servers[0], { name: "broken", authStatus: "definitely-not-valid" }], + }); + const { loadCachedInventory } = await freshCache(dir); + // Not a one-server inventory: the good entry goes with the bad one. + expect(loadCachedInventory().reason).toBe("capture-invalid"); + expect(loadCachedInventory().servers).toBeUndefined(); + }); + + it("rejects a server with no name", async () => { + seed(dir, "sess-1", { ...VALID, servers: [{ authStatus: "unknown" }] }); + const { loadCachedInventory } = await freshCache(dir); + expect(loadCachedInventory().reason).toBe("capture-invalid"); + }); + + // The file is written by a separate process which may be a different plugin version, + // so an unrecognized key must not ride along into the payload unreviewed. + it("drops keys it does not know about", async () => { + seed(dir, "sess-1", { + source: "host-cli", + servers: [ + { + name: "glean_default", + url: "https://acme-be.glean.com/mcp", + authStatus: "authenticated", + env: { GLEAN_API_TOKEN: "leaked" }, + launchPath: "/Users/someone/secrets/start.mjs", + }, + ], + }); + const { loadCachedInventory } = await freshCache(dir); + + const result = loadCachedInventory(); + expect(result.servers).toEqual([ + { name: "glean_default", url: "https://acme-be.glean.com/mcp", authStatus: "authenticated" }, + ]); + expect(JSON.stringify(result)).not.toContain("leaked"); + expect(JSON.stringify(result)).not.toContain("secrets"); + }); + + it("omits withheld when it is not a sane count", async () => { + seed(dir, "sess-1", { ...VALID, withheld: -3 }); + const { loadCachedInventory } = await freshCache(dir); + expect(loadCachedInventory().withheld).toBeUndefined(); + }); + + // The hook writes a negative marker when it ran and came back with nothing, which is + // what separates "the hook never fired" from "the hook fired and the CLI was missing". + it("surfaces the reason the hook recorded", async () => { + seed(dir, "sess-1", { source: "unavailable", reason: "cli-unavailable" }); + const { loadCachedInventory } = await freshCache(dir); + expect(loadCachedInventory().reason).toBe("cli-unavailable"); + }); + + // The reason goes onto the wire, and the file is untrusted input like everything else + // in it -- a hook from another build, or anything with write access to the directory. + // Passing it through unchecked would let a file put arbitrary text in a request. + it("does not pass an unrecognized reason through to the wire", async () => { + seed(dir, "sess-1", { + source: "unavailable", + reason: "cli-unavailable\" injected: \"see https://internal.acme.com", + }); + const { loadCachedInventory } = await freshCache(dir); + + const result = loadCachedInventory(); + expect(result.reason).toBe("capture-invalid"); + expect(JSON.stringify(result)).not.toContain("internal.acme.com"); + }); + + it("names the field that failed, for the log only", async () => { + seed(dir, "sess-1", { + ...VALID, + servers: [VALID.servers[0], { name: "whatever", authStatus: "connected" }], + }); + const { loadCachedInventory, lastInventoryDiagnostic } = await freshCache(dir); + loadCachedInventory(); + + // "We saw connected, we expect authenticated" names a version skew outright, so this + // one value earns its place -- it passes an enum-shaped guard first. + expect(lastInventoryDiagnostic()).toMatchObject({ + detail: "server entry rejected", + entries: 2, + badIndex: 1, + badField: "authStatus", + badValue: "connected", + }); + }); + + // The same field, holding something that is not enum-shaped. A rejected file is exactly + // where its contents are least trustworthy, so anything that could be a token, a + // hostname, or a path is withheld even from the local log. + it("withholds a bad value that is not enum-shaped", async () => { + seed(dir, "sess-1", { + ...VALID, + servers: [{ name: "x", authStatus: "Bearer sk-abc123/internal.acme.com" }], + }); + const { loadCachedInventory, lastInventoryDiagnostic } = await freshCache(dir); + loadCachedInventory(); + + const diagnostic = lastInventoryDiagnostic(); + expect(diagnostic?.badField).toBe("authStatus"); + expect(diagnostic?.badValue).toBeUndefined(); + expect(JSON.stringify(diagnostic)).not.toContain("sk-abc123"); + }); + + // A server name may be a third party's, so it is never logged even though it is the + // most obvious thing to reach for when an entry is rejected. + it("never logs a server name", async () => { + seed(dir, "sess-1", { + ...VALID, + servers: [{ name: "acme-payroll-internal", authStatus: 42 }], + }); + const { loadCachedInventory, lastInventoryDiagnostic } = await freshCache(dir); + loadCachedInventory(); + + expect(JSON.stringify(lastInventoryDiagnostic())).not.toContain("payroll"); + }); + + it("clears the diagnostic once a read succeeds", async () => { + seed(dir, "sess-1", { ...VALID, source: "nonsense" }); + const { loadCachedInventory, lastInventoryDiagnostic } = await freshCache(dir); + loadCachedInventory(); + expect(lastInventoryDiagnostic()).toBeDefined(); + + seed(dir, "sess-1", VALID); + loadCachedInventory(); + expect(lastInventoryDiagnostic()).toBeUndefined(); + }); + + it("reports a genuinely empty inventory as host-cli, not unavailable", async () => { + seed(dir, "sess-1", { source: "host-cli", servers: [], withheld: 0 }); + const { loadCachedInventory } = await freshCache(dir); + + // "The host says you have no Glean servers" and "we could not ask" are different + // facts, and only the second one is `unavailable`. + expect(loadCachedInventory()).toEqual({ source: "host-cli", servers: [], withheld: 0 }); + }); +}); + +// The reason this module exists. `claude mcp list` health-checks every server, which +// SPAWNS each stdio one -- including this plugin, whose spawned copy goes on to serve a +// full tools/list with a live remote fetch. A shell-out from the read path would +// therefore recurse without bound, one process and one backend call per level. The +// invariant is load-bearing enough to be asserted rather than commented. +describe("the read path never runs a subprocess", () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "inv-nospawn-")); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("spawns nothing, on the hit path or the miss path", async () => { + const { loadCachedInventory } = await freshCache(dir); + expect(loadCachedInventory().source).toBe("unavailable"); + + seed(dir, "sess-1", VALID); + expect(loadCachedInventory().source).toBe("host-cli"); + + // Not merely "no spawn happened": the mock throws, and loadCachedInventory catches + // everything to fail open, so an attempt would otherwise be swallowed into a + // plausible `unavailable`. The counter is what distinguishes the two. + expect(spawnAttempts).toEqual([]); + }); + + // A lazy require inside a catch block would defeat the spies above, so the source is + // also checked for the imports that would make a spawn possible at all. + it("does not even import a process-spawning module", async () => { + const here = path.dirname(new URL(import.meta.url).pathname); + const source = fs.readFileSync( + path.join(here, "../src/policy/inventory-cache.ts"), + "utf-8", + ); + expect(source).not.toContain("child_process"); + }); +});