inspector/core/auth/node/secret-store-selection.ts
cliffhall decc673a5d fix(auth): address Copilot review round 5, and land the impl its tests need
Repairs origin as well as answering the review. 2d2c787e committed the
`absorbFileSecretsIntoKeyring` tests without the implementation they
import, so the branch has been failing `tsc` since; this commit brings the
source across.

Round 5's four findings (two inline, two suppressed):

- **The stale-lock takeover was still racy.** Round 4 made *stealing*
  liveness-aware but left the takeover as remove-then-recreate, which is
  not an election: two waiters can both judge the same lock stale, the
  first removes it and takes a fresh one, and the second then removes
  *that* — both inside the section, which is the lost update the takeover
  exists to prevent. Claiming is now an atomic `rename` to a
  token-unique name, so exactly one waiter can take a given stale lock and
  the losers get ENOENT. A failed claim also falls through to the deadline
  instead of `continue`ing, so a takeover that can never succeed times out
  rather than spinning forever on a swallowed error.

- **Every `stat` failure was read as "no file yet".** An existing file we
  cannot inspect (EACCES on the directory, a mode-less filesystem) then
  produced no warning at all, and the footer went on stating owner-only
  permissions having verified nothing. There is now a third state,
  `unknown`, carried end to end: `permissionsUnknown` on the descriptor, a
  caveat that says the permissions could not be checked, a `warn` tone, and
  its own branch in the footer sentence — which otherwise would have
  claimed "Owner-only permissions." by omission, the same bug one layer up.

- **mountinfo decoded only `\040`.** The kernel escapes tab, newline and
  backslash too (`\011`, `\012`, `\134`), so a mounted path containing any
  of them was compared in its escaped form, matched nothing, and read as
  the container's writable layer — `memory` selected and persistence
  silently lost on a box that had arranged for it. Decoded as octal in
  general now, since the rule is octal-in-general and a future addition
  would otherwise reintroduce exactly this.

- **File-backed secrets vanished once a keychain appeared.** Save secrets
  without libsecret, install libsecret, and the next run selects the
  keychain and stops seeing them: still on disk, read by nothing, and
  nothing looks broken. `absorbFileSecretsIntoKeyring` hands them over on
  the run that finds a keychain, under the rules the two existing plaintext
  migrations already use — keychain wins on conflict, the file is removed
  only on complete success, and a file that cannot be read is never
  deleted, because that is precisely the case where the values would be
  lost.

Tests: a two-waiter race over one stale lock asserting a single holder
throughout; `unknown` from `readSecretFilePermissions` through the
descriptor to the footer sentence and tone; the hand-off's happy path,
keychain-wins, write-failure, undecryptable-file, empty-file,
malformed-key, and explicitly-configured-keyring cases; and six more
rejected descriptor shapes, including each file-only field on a non-file
kind. Full `npm run ci` green — 5,732 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:15:35 -04:00

572 lines
22 KiB
TypeScript

/**
* Which {@link SecretStore} this process uses, and how the user finds out
* (#1950).
*
* ## The policy
*
* `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright. With it
* unset:
*
* 1. Probe the OS keychain. Reachable → use it. This is the overwhelming
* majority of runs and nothing changes for them.
* 2. Unreachable → fall back, **loudly**. Which way we fall back depends
* on whether the file we would write is going to survive:
* - in a container whose secrets directory is *not* on a mount →
* `memory`;
* - otherwise → `file`.
*
* ## Why automatic
*
* Automatic fallback silently changes where a secret lives, which is a
* real objection to make of a security-adjacent tool. But the objection
* is to *silently*, not to *automatic*: requiring an env var before any
* persistence works at all means the container user's first encounter is
* a 503 with no hint that a working configuration exists — and the store
* they would then be told to set is the one this code would have picked
* anyway. So we take the friendly default and pay for it in noise. The
* downgrade prints a banner, rides `GET /api/config`, and is named in the
* footer of both settings modals — permanently, where the secret is
* typed, rather than once at startup where the person typing it 20
* minutes later will never see it.
*
* ## Why memory can be the better answer
*
* A file in a container's writable layer looks like persistence and isn't:
* `docker run --rm` discards it, and so does any image update. An
* in-memory store makes no such promise, and says so. Mounting a volume —
* which the README already recommends for the catalog — flips the same
* run to `file` with no configuration, because the mount is the user
* stating that this directory is meant to survive.
*/
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
type SecretStorageInfo,
type SecretStoreKind,
secretStorageCaveat,
secretStorageSummary,
} from "../secret-storage-info.js";
import {
FileSecretStore,
readSecretFilePermissions,
tightenSecretFilePermissions,
} from "./file-secret-store.js";
import {
KeyringSecretStore,
parseAccount,
probeKeyringAvailable,
secretStoreIsDurable,
SessionSecretStore,
type SecretStore,
} from "./secret-store.js";
/** Env var naming an explicit store, bypassing the probe entirely. */
export const SECRET_STORE_ENV = "MCP_INSPECTOR_SECRET_STORE";
/** Env var overriding the secrets file location outright. */
export const SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE";
/**
* Env var relocating the Inspector's durable state directory. Already
* honored by the web backend for OAuth tokens and `client.json`; the
* secrets file follows it for the same reason, and because the mount check
* below asks whether *that* directory survives. A container mounting only
* the configured storage directory would otherwise be judged unmounted,
* fall back to `memory`, and lose secrets the user had arranged to keep.
*/
export const STORAGE_DIR_ENV = "MCP_STORAGE_DIR";
const KINDS: SecretStoreKind[] = ["keyring", "file", "memory"];
export interface ResolvedSecretStore {
store: SecretStore;
info: SecretStorageInfo;
}
/**
* Parse {@link SECRET_STORE_ENV}. An unrecognized value returns undefined
* (falling through to the automatic policy) after a warning rather than
* throwing: a typo in a compose file should not stop the Inspector from
* starting, and the user needs the correct spellings more than a stack.
*/
export function parseSecretStoreEnv(
raw: string | undefined,
): SecretStoreKind | undefined {
const value = raw?.trim().toLowerCase();
if (!value) return undefined;
if ((KINDS as string[]).includes(value)) return value as SecretStoreKind;
console.warn(
`[mcp-inspector] Ignoring ${SECRET_STORE_ENV}="${raw}": expected one of ${KINDS.join(", ")}.`,
);
return undefined;
}
/**
* Default secrets file: `MCP_INSPECTOR_SECRET_FILE` if named outright,
* otherwise `secrets.json` in the configured storage directory, otherwise
* beside the catalog in `~/.mcp-inspector`.
*/
export function defaultSecretFilePath(): string {
const override = process.env[SECRET_FILE_ENV]?.trim();
if (override) return path.resolve(override);
const storageDir = process.env[STORAGE_DIR_ENV]?.trim();
if (storageDir) return path.resolve(storageDir, "secrets.json");
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
return path.join(homeDir, ".mcp-inspector", "secrets.json");
}
/**
* Are we in a container?
*
* Three signals, any of which is sufficient, because no single one covers
* the runtimes people actually use: `/.dockerenv` is Docker-specific and
* absent under Podman and containerd; the `/proc/1/cgroup` scan catches
* those but reads like the host's under cgroup v2 on some setups; and
* `KUBERNETES_SERVICE_HOST` catches a pod regardless of runtime.
*
* A false positive here is cheap and a false negative is cheaper still —
* this only *biases the fallback* between two working stores, and the
* mount check below is what actually decides. Nothing about correctness
* rests on getting this exactly right, which is why it is allowed to be a
* heuristic at all.
*/
export function isContainer(): boolean {
if (process.env.KUBERNETES_SERVICE_HOST) return true;
try {
// `/.dockerenv` is Docker's marker; `/run/.containerenv` is Podman's
// equivalent and is the only one a rootless Podman container reliably
// has. Missing it classified such a container as a host, which is the
// wrong way round to be wrong: the fallback then writes a file into the
// ephemeral writable layer and calls it durable.
if (
fsSync.existsSync("/.dockerenv") ||
fsSync.existsSync("/run/.containerenv")
) {
return true;
}
} catch {
// A sandboxed / restricted filesystem. Fall through to the cgroup read.
}
try {
const cgroup = fsSync.readFileSync("/proc/1/cgroup", "utf-8");
// `libpod` is what Podman actually writes into cgroup paths
// (`/machine.slice/libpod-<id>.scope`); the literal string "podman"
// often does not appear at all.
return /\b(docker|containerd|kubepods|podman|libpod|lxc)\b/.test(cgroup);
} catch {
// Not Linux, or /proc is not mounted. Not a container as far as we know.
return false;
}
}
/**
* Nearest ancestor of `dir` that actually exists.
*
* The directory usually does not exist on a first run — `~/.mcp-inspector`
* is created on demand — so every check below has to ask about the closest
* thing that does. A volume mounted *at* the target path exists by
* definition, so the common positive case needs no walk at all.
*/
function nearestExisting(dir: string): string | null {
let current = path.resolve(dir);
while (!fsSync.existsSync(current)) {
const parent = path.dirname(current);
/* v8 ignore next -- @preserve: the walk can only run out of ancestors if
the filesystem root itself does not exist, which would mean the process
has no cwd to have resolved against. Kept as the loop's termination
guard rather than trusting that invariant. */
if (parent === current) return null;
current = parent;
}
return current;
}
/**
* Mount points visible to this process, from `/proc/self/mountinfo`, or
* `null` where that file cannot be read (macOS, Windows, a `/proc`-less
* container).
*
* Field 5 of each line is the mount point, octal-escaped. `/` is dropped:
* in a container that is the writable layer, which is exactly what the
* durability question is asking *against*.
*/
/**
* Undo the kernel's octal escaping of a mountinfo path.
*
* The field is space-delimited, so `show_mountinfo` escapes anything that
* would break parsing — space (`\040`), tab (`\011`), newline (`\012`) and
* the backslash itself (`\134`). Decoding only the space left a mounted
* path containing any of the others compared in its escaped form, so it
* matched nothing, and a genuinely mounted volume read as the container's
* writable layer: `memory` selected, persistence silently lost on a box
* that had arranged for it.
*
* Handled as one general `\ooo` pass rather than four literals, since the
* escaping rule is octal-in-general and a future addition would otherwise
* reintroduce exactly this bug.
*/
function unescapeMountPoint(point: string): string {
return point.replace(/\\(\d{3})/g, (_match, octal: string) =>
String.fromCharCode(parseInt(octal, 8)),
);
}
function readMountPoints(): string[] | null {
try {
return fsSync
.readFileSync("/proc/self/mountinfo", "utf-8")
.split("\n")
.map((line) => line.split(" ")[4])
.filter((point): point is string => Boolean(point))
.map(unescapeMountPoint)
.filter((point) => point !== "/");
} catch {
return null;
}
}
/**
* Does `dir` live on something mounted over the root filesystem?
*
* Two mechanisms, because either one alone gets a realistic case wrong.
*
* **`/proc/self/mountinfo` first**, where it exists. It answers the question
* actually being asked — "is this path *under* a mount" — rather than the
* narrower "is this path *itself* a mount". The difference is a real
* container layout: mounting a volume at `/home/node` leaves
* `/home/node/.mcp-inspector` an ordinary subdirectory sharing its parent's
* device, so a device-boundary test answers "not mounted" and demotes a
* durable setup to session-only `memory`. Reading the mount table also sees
* a bind mount that happens to come from the same device, which no `st_dev`
* comparison can.
*
* **The `st_dev` comparison as the fallback**, and against `/` rather than
* against the immediate parent — so a subdirectory of a mount is still
* recognized. Outside a container this barely matters (the fallback there is
* `file` either way); it matters in a `/proc`-less container, where being
* wrong costs durability.
*
* Errors answer `false`, keeping the fallback conservative — `memory` in a
* container — which never promises to keep a secret it then loses.
*/
export function isOnMountPoint(dir: string): boolean {
try {
const current = nearestExisting(dir);
if (current === null) return false;
const mounts = readMountPoints();
if (mounts !== null) {
return mounts.some(
(point) => current === point || current.startsWith(`${point}/`),
);
}
const parent = path.dirname(current);
if (parent === current) return false; // `dir` resolved to the root itself
return fsSync.statSync(current).dev !== fsSync.statSync("/").dev;
} catch {
// Permission denied, a racing unmount, or a platform where `dev` is not
// meaningful.
return false;
}
}
/** Build the store named by `kind`, plus the descriptor that explains it. */
async function buildStore(
kind: SecretStoreKind,
reason: SecretStorageInfo["reason"],
detail?: string,
): Promise<ResolvedSecretStore> {
if (kind === "keyring") {
return {
store: new KeyringSecretStore(),
info: { kind, reason, durable: true },
};
}
if (kind === "memory") {
// `SessionSecretStore`, not the bare `InMemorySecretStore` the tests
// use: it reports `isDurable() === false`, which is what stops the
// mcp.json / client.json migrations from stripping a plaintext secret
// off disk in exchange for a copy that dies with this process.
return {
store: new SessionSecretStore(),
info: { kind, reason, durable: false, detail },
};
}
const filePath = defaultSecretFilePath();
const store = new FileSecretStore({ filePath });
// Repair a loosened mode on a pre-existing file, and — unlike before —
// *verify* the result. The UI and the README both state the file is 0600,
// so a file we could not tighten (owned by another user, on a read-only
// mount) has to be reported rather than quietly described as protected.
const perms = await tightenSecretFilePermissions(filePath);
if (perms.state === "loose") {
console.warn(
`[mcp-inspector] The secrets file at ${filePath} is mode ${perms.mode.toString(8).padStart(4, "0")}, not 0600, and could not be tightened. Anyone who can read it can read the secrets in it.`,
);
}
return {
store,
info: await describeFileStore(store, filePath, kind, reason, detail),
};
}
/**
* Build the descriptor for a file store by *looking at the file*.
*
* `store.encrypted` is what the next write will do; a file already on disk in
* the clear stays readable until that write happens, and saying "File
* (encrypted)" in the meantime is the one false reassurance this whole
* subsystem exists not to give. With no file yet (or one we cannot parse) the
* policy is all there is to report, and it is then accurate — the first write
* creates the file in that mode.
*
* Split out from `buildStore` so it can be re-run: this is a property of a
* file that changes underneath us, not a property of the selection.
*/
async function describeFileStore(
store: FileSecretStore,
filePath: string,
kind: SecretStoreKind,
reason: SecretStorageInfo["reason"],
detail?: string,
): Promise<SecretStorageInfo> {
const onDisk = await store.readOnDiskEncryption();
const plaintext = onDisk === null ? !store.encrypted : onDisk === "none";
// Re-checked here rather than captured at selection, for the same reason
// the encryption state is: the mode can change under a running process,
// and this descriptor is what the browser renders as fact. The *read-only*
// half deliberately — describing must not chmod, least of all once per
// page load on a file another process may be mid-write on. The repair
// happens once, at selection.
const perms = await readSecretFilePermissions(filePath);
return {
kind,
reason,
path: filePath,
plaintext,
...(perms.state === "loose" ? { looseMode: perms.mode } : {}),
...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}),
// Only meaningful while the two disagree; omitted otherwise so the
// payload does not carry a flag that says nothing.
...(plaintext && store.encrypted ? { pendingEncryption: true } : {}),
durable: true,
detail,
};
}
/**
* Pick the fallback store for a host with no keychain. Exported for the
* tests, which drive the container/mount predicates directly rather than
* trying to make a real container appear.
*/
export function chooseFallbackKind(opts: {
container: boolean;
mounted: boolean;
}): SecretStoreKind {
return opts.container && !opts.mounted ? "memory" : "file";
}
/**
* Print the fallback / plaintext warnings.
*
* `console.warn` rather than the pino file logger deliberately: the person
* who needs this is watching a terminal or `docker logs`, and store
* selection happens before (and independently of) any logger being
* configured.
*/
export function warnAboutSecretStorage(info: SecretStorageInfo): void {
if (info.reason === "fallback") {
console.warn(
`\n[mcp-inspector] The OS keychain is not available, so secrets will be kept in: ${secretStorageSummary(info)}.` +
(info.detail
? `\n[mcp-inspector] Keychain error: ${info.detail}`
: "") +
`\n[mcp-inspector] Set ${SECRET_STORE_ENV}=keyring|file|memory to choose explicitly.`,
);
}
const caveat = secretStorageCaveat(info);
if (caveat) console.warn(`[mcp-inspector] ${caveat}`);
}
let resolved: Promise<ResolvedSecretStore> | undefined;
/**
* Resolve the active store, once per process.
*
* Cached because the probe is a native round-trip and, more importantly,
* because the answer must not differ between two callers: the banner, the
* `/api/config` payload, and the store actually doing the writing are
* three consumers of one decision, and a re-probe that flipped would have
* the UI describing a store nobody is using. There is no reset seam for
* the same reason a platform does not grow a keychain mid-run; tests get a
* fresh module (and so a fresh cache) via `vi.resetModules()`.
*/
export function resolveSecretStore(): Promise<ResolvedSecretStore> {
resolved ??= (async (): Promise<ResolvedSecretStore> => {
const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]);
if (configured) {
const result = await buildStore(configured, "configured");
if (configured === "keyring") {
await absorbFileSecretsIntoKeyring(result.store);
}
warnAboutSecretStorage(result.info);
return result;
}
const probe = await probeKeyringAvailable();
if (probe.available) {
const result = await buildStore("keyring", "default");
await absorbFileSecretsIntoKeyring(result.store);
return result;
}
const kind = chooseFallbackKind({
container: isContainer(),
mounted: isOnMountPoint(path.dirname(defaultSecretFilePath())),
});
const result = await buildStore(kind, "fallback", probe.detail);
warnAboutSecretStorage(result.info);
return result;
})();
return resolved;
}
/**
* Hand a leftover secrets file over to the keychain, once one exists.
*
* The transition this closes: a box with no libsecret falls back to
* `secrets.json` and the user saves an OAuth client secret there. They
* install libsecret. The next run probes successfully, selects the keychain
* — and every value they saved becomes invisible, because nothing reads the
* file any more. The data is still on disk, which makes it worse rather
* than better: nothing looks broken, the secrets have simply gone, and the
* file sits there as an unexplained leftover.
*
* The rules are the ones the two existing plaintext migrations already use,
* for consistency rather than novelty:
*
* - **The keychain wins on conflict.** An account already present there is
* left alone; the file's value is treated as the older copy.
* - **The file is removed only on complete success.** A partial hand-off
* leaves it exactly as it was, so the next run retries and the
* keychain-wins rule silently absorbs whatever already made it across.
* - **An unreadable file is not an empty one.** If it cannot be decrypted
* (the passphrase changed, or is now unset) this reports and returns
* rather than deleting it — the whole point is not to lose the values.
*
* Never throws. A keychain that fails mid-hand-off leaves the file intact,
* which is the safe state, and the user still gets a working session.
*/
export async function absorbFileSecretsIntoKeyring(
keyring: SecretStore,
): Promise<void> {
const filePath = defaultSecretFilePath();
if (!fsSync.existsSync(filePath)) return;
const file = new FileSecretStore({ filePath });
let entries: Record<string, string> | null;
try {
entries = await file.readAll();
} catch (err) {
console.warn(
`\n[mcp-inspector] The OS keychain is available again, but the secrets file at ${filePath} could not be read, so its contents were left there: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
if (!entries || Object.keys(entries).length === 0) return;
try {
for (const [account, value] of Object.entries(entries)) {
const parsed = parseAccount(account);
if (!parsed) continue;
const existing = await keyring.get(parsed.serverId, parsed.field);
if (existing === null) {
await keyring.set(parsed.serverId, parsed.field, value);
}
}
} catch (err) {
console.warn(
`\n[mcp-inspector] Could not move the secrets file at ${filePath} into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
try {
await fs.rm(filePath, { force: true });
console.warn(
`\n[mcp-inspector] The OS keychain is available again. Secrets kept in ${filePath} while it was not have been moved into the keychain, and the file has been removed.`,
);
} catch {
// Every value is in the keychain; only the now-redundant file remains.
// Not worth failing startup over, and the keychain-wins rule makes the
// next attempt harmless.
}
}
/**
* The active store's descriptor — for the banner and `GET /api/config`.
*
* The *selection* is cached, deliberately (see {@link resolveSecretStore}).
* The descriptor is not, for the file store: `plaintext` and
* `pendingEncryption` describe bytes on disk that this very process
* changes. Cache them and the first `set` under a newly-set passphrase
* encrypts the file while every subsequent `/api/config` keeps serving
* "still unencrypted" until a restart — stale in the safe direction, but
* stale about the one fact this surface exists to state. Re-deriving costs
* one read of a small file per config fetch, which is once per page load.
*/
export async function getSecretStorageInfo(): Promise<SecretStorageInfo> {
const { store, info } = await resolveSecretStore();
if (info.kind !== "file" || !(store instanceof FileSecretStore)) return info;
return describeFileStore(
store,
store.filePath,
"file",
info.reason,
info.detail,
);
}
/**
* A `SecretStore` that resolves the real one on first use.
*
* Selection is async (it probes the keychain) but every existing default
* is a synchronous `new KeyringSecretStore()` in the middle of an options
* object — `core/mcp/node/servers.ts`, `core/client/runner.ts`,
* `core/mcp/remote/node/server.ts`. Making those async would ripple
* through their callers to no benefit, since each method is already async
* and can simply await the selection it needs. So the indirection lives
* here, in one place, instead of in three signatures.
*/
class DeferredSecretStore implements SecretStore {
private async target(): Promise<SecretStore> {
return (await resolveSecretStore()).store;
}
async isDurable(): Promise<boolean> {
return secretStoreIsDurable(await this.target());
}
async get(serverId: string, field: string): Promise<string | null> {
return (await this.target()).get(serverId, field);
}
async set(serverId: string, field: string, value: string): Promise<void> {
await (await this.target()).set(serverId, field, value);
}
async delete(serverId: string, field: string): Promise<void> {
await (await this.target()).delete(serverId, field);
}
async deleteAllForServer(serverId: string): Promise<void> {
await (await this.target()).deleteAllForServer(serverId);
}
}
/**
* The default store for production call sites — what `new
* KeyringSecretStore()` used to be. Every one of them now routes through
* the same selection, so the CLI, the TUI, and the web backend cannot end
* up writing to different places on the same box.
*/
export function defaultSecretStore(): SecretStore {
return new DeferredSecretStore();
}