fix(auth): address Copilot review round 2 on the secret-store PR

Seven findings, all taken.

- **The descriptor went stale after the upgrading write.** Round 1 made it
  read the file instead of the policy, but it was still computed once and
  cached beside the selection — so the first `set` under a newly-set
  passphrase encrypted the file while `/api/config` kept serving "still
  unencrypted" for the rest of the session, describing bytes this very
  process had just changed. The *selection* stays cached (the banner, the
  payload, and the store doing the writing must agree on which store it
  is); the file descriptor is re-derived per call, which costs one read of
  a small file per config fetch.

- **The mount check asked the wrong question.** Comparing a directory's
  `st_dev` against its parent's detects a mount *at* that path, but a
  volume mounted at `/home/node` leaves `/home/node/.mcp-inspector` an
  ordinary subdirectory sharing its parent's device — so a durable
  container setup was judged unmounted and demoted to session-only
  `memory`. It now reads `/proc/self/mountinfo` and asks whether the
  directory is *under* any mount other than `/`, which also catches the
  same-device bind mount no `st_dev` test can see. Where mountinfo is
  unreadable (macOS, Windows, a `/proc`-less container) it falls back to
  the device comparison, now against `/` rather than the parent.

- **A decrypted payload was cast, not checked.** GCM proves the bytes are
  authentic, not that they parse to the right shape. An array passes
  `typeof === "object"`, takes the named assignment, and then
  `JSON.stringify` drops every named property of an array — so `set` would
  resolve having written a file without the secret it was handed. Silent
  loss on the one operation whose entire contract is that it does not lose
  things. Both branches now validate a non-array object of string values,
  and a shape refusal is deliberately not rethrown as a key mismatch,
  which would send the user to fix a working passphrase.

- **The read-modify-write cycle was only serialized in-process.** A
  durable file is shared state and a second Inspector on the same box (a
  CLI run beside a web session) could read the same map and overwrite the
  first one's save, both reporting success. Mutations now take a
  `mkdir`-based lock. A lock left by a killed process is stolen after 10s
  — one `docker kill` must not brick secret writes permanently, which
  would be a worse failure than the race. If the lock cannot be created at
  all, the write proceeds unlocked rather than denying a real user a real
  secret to prevent a race with a process that may not exist. Reads do not
  lock: `writeStoreFile` is atomic, so a reader sees the old file or the
  new one, never a torn one.

- **The permission repair was fire-and-forget while the docs promised
  0600.** A file owned by another user, or on a read-only mount, stayed
  exposed while the footer described it as protected. It now verifies the
  resulting mode and warns, naming the actual mode, when it could not be
  tightened.

- **`Group`'s theme carried a domain-named `secretStorageFooter`
  variant**, which AGENTS.md reserves for app-wide primitive styling.
  Renamed to the structural `stickyModalFooter`; the secret-storage
  meaning stays in the element that owns it.

- The PR description still claimed the salt made a short passphrase safe,
  contradicting the corrected comment and README. Updated.

Tests: both mount layouts that motivated the change (mount-at and
mount-above) plus the sibling-prefix, space-escaped, first-run, and
no-mountinfo cases; the array and non-string refusals on both the
plaintext and the authentic-ciphertext paths (the latter through a
hand-built fixture, since the store's own writer cannot produce that
state); lock exclusion, stale-lock stealing, the un-lockable fallback, and
two independent store instances racing; the permission report in all three
states; and the descriptor no longer going stale within one process. Full
`npm run ci` green — 5,688 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 12:51:14 -04:00
commit de7795ca54
2 changed files with 716 additions and 96 deletions

View file

@ -122,6 +122,37 @@ interface SecretFile {
const buildAccount = (serverId: string, field: string): string =>
`${serverId}:${field}`;
/**
* Assert that a decoded payload really is a `Record<string, string>`.
*
* Neither branch of {@link FileSecretStore.readMap} can trust its input: the
* plaintext `secrets` field is whatever the file says, and a decrypted
* payload only proves the *bytes* were authentic — GCM verifies who wrote
* them, not what shape they parse to.
*
* An array is the case that shows why a cast is not enough. `[]` passes
* `typeof === "object"`, so `map[account] = value` assigns a named property
* to it, and `JSON.stringify` then drops every named property of an array —
* so `set` resolves successfully having written a file that does not contain
* the secret it was given. Silent loss on the one operation whose entire
* contract is that it does not lose things.
*/
function asSecretMap(value: unknown, filePath: string): Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SecretStoreUnavailableError(
`The secrets file at ${filePath} does not hold a secret map (found ${Array.isArray(value) ? "an array" : typeof value}). Refusing to write, which would replace it. Move the file aside to start over.`,
);
}
for (const [key, entry] of Object.entries(value)) {
if (typeof entry !== "string") {
throw new SecretStoreUnavailableError(
`The secrets file at ${filePath} holds a non-string value for "${key}". Refusing to write, which would replace it. Move the file aside to start over.`,
);
}
}
return { ...(value as Record<string, string>) };
}
/**
* Thrown when a file was written encrypted and the current passphrase
* cannot open it. A distinct type so `set` can refuse with advice that
@ -161,6 +192,83 @@ function decodeParts(
};
}
/**
* How long a lock may be held before another process treats it as
* abandoned. Generous next to the work it guards (a read, one scrypt
* derivation, an atomic write — single-digit milliseconds), because the
* cost of being wrong is asymmetric: waiting a second too long is
* invisible, while stealing a live lock reintroduces the very race the
* lock exists to close.
*/
const LOCK_STALE_MS = 10_000;
/** Total time to wait for a lock before giving up and reporting it. */
const LOCK_TIMEOUT_MS = 5_000;
const LOCK_POLL_MS = 25;
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Take an exclusive, cross-process lock on `filePath`.
*
* `mkdir` is the primitive rather than a lock *file*: it is atomic and
* fails with `EEXIST` on every POSIX filesystem and on Windows, and it
* needs no `O_EXCL` open-flag juggling. Returns the release function.
*
* **Stale locks are stolen, and that is not optional.** A container killed
* with SIGKILL leaves the directory behind, and a lock that can only be
* released by a cooperative exit would make one `docker kill` brick secret
* writes permanently — turning a rare lost-write race into a permanent
* outage, which is a strictly worse failure. Staleness is judged by the
* directory's mtime.
*
* Never throws for lock-infrastructure reasons alone. If the lock cannot
* be created at all (a read-only mount, a filesystem without mkdir
* semantics we can rely on), the caller proceeds unlocked: the in-process
* queue still protects the common single-process case, and refusing to
* write would deny a real user a real secret in order to prevent a race
* with a second process that may not exist.
*/
export async function acquireLock(
filePath: string,
): Promise<() => Promise<void>> {
const lockDir = `${filePath}.lock`;
const noop = async (): Promise<void> => {};
const deadline = Date.now() + LOCK_TIMEOUT_MS;
for (;;) {
try {
await fs.mkdir(lockDir, { recursive: false });
return async () => {
try {
await fs.rmdir(lockDir);
} catch {
// Already gone — another process judged us stale and stole it.
// Nothing to undo, and nothing the caller can act on.
}
};
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== "EEXIST") {
// Not "someone else holds it" — the lock itself is unavailable.
// Proceed unlocked rather than failing the write; see above.
return noop;
}
try {
const held = (await fs.stat(lockDir)).mtimeMs;
if (Date.now() - held > LOCK_STALE_MS) {
await fs.rmdir(lockDir);
continue;
}
} catch {
// The holder released it between our mkdir and our stat. Retry.
}
if (Date.now() >= deadline) return noop;
await sleep(LOCK_POLL_MS);
}
}
}
export interface FileSecretStoreOptions {
/** Absolute path of the secrets file. */
filePath: string;
@ -285,7 +393,7 @@ export class FileSecretStore implements SecretStore {
// lands the file really is still readable, which is why
// `readOnDiskEncryption` exists — the descriptor must report the file,
// not the intent.
return { ...(parsed.secrets ?? {}) };
return asSecretMap(parsed.secrets ?? {}, this.filePath);
}
if (parsed.encryption !== CIPHER || !parsed.kdf || !parsed.data) {
@ -306,8 +414,12 @@ export class FileSecretStore implements SecretStore {
decipher.update(parts.body),
decipher.final(),
]).toString("utf-8");
return JSON.parse(plain) as Record<string, string>;
} catch {
return asSecretMap(JSON.parse(plain), this.filePath);
} catch (err) {
// A shape refusal is about the *contents*, not the key — it decrypted
// fine. Rethrowing it as a key mismatch would send the user to fix a
// passphrase that is working.
if (err instanceof SecretStoreUnavailableError) throw err;
// GCM authentication failure is what a wrong passphrase looks like, and
// it is indistinguishable from a tampered/corrupt file — both mean "this
// key does not open this file", which is what the message says.
@ -351,6 +463,31 @@ export class FileSecretStore implements SecretStore {
await writeStoreFile(this.filePath, `${JSON.stringify(file, null, 2)}\n`);
}
/**
* Run `fn` with the file to itself across *processes* as well.
*
* The in-process queue is necessary and not sufficient: a durable secrets
* file is shared state, and a second Inspector on the same box — a CLI run
* beside a web session is the ordinary case, not a contrived one — reads
* the same "before" map and then atomically replaces the file, dropping
* whatever the first process had just added. Both writes report success;
* one secret is simply gone.
*
* Only mutations take the lock. Reads deliberately do not: `writeStoreFile`
* is atomic (write-temp-then-rename), so a concurrent reader sees either the
* old file or the new one and never a torn one, and making every `get`
* contend on a lock would put a filesystem round-trip in front of the
* request path for no correctness gain.
*/
private async withFileLock<T>(fn: () => Promise<T>): Promise<T> {
const release = await acquireLock(this.filePath);
try {
return await fn();
} finally {
await release();
}
}
/** Run `fn` with the file to itself, so concurrent callers can't lose writes. */
private serialize<T>(fn: () => Promise<T>): Promise<T> {
const run = this.queue.then(fn);
@ -380,27 +517,29 @@ export class FileSecretStore implements SecretStore {
}
async set(serverId: string, field: string, value: string): Promise<void> {
await this.serialize(async () => {
let map: Record<string, string>;
try {
map = (await this.readMap()) ?? {};
} catch (err) {
// Already a typed refusal (unreadable file / key mismatch) — rethrow
// rather than re-wrapping, so the specific advice survives.
if (err instanceof SecretStoreUnavailableError) throw err;
throw new SecretStoreUnavailableError(
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
map[buildAccount(serverId, field)] = value;
try {
await this.writeMap(map);
} catch (err) {
throw new SecretStoreUnavailableError(
`Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
});
await this.serialize(() =>
this.withFileLock(async () => {
let map: Record<string, string>;
try {
map = (await this.readMap()) ?? {};
} catch (err) {
// Already a typed refusal (unreadable file / key mismatch) —
// rethrow rather than re-wrapping, so the specific advice survives.
if (err instanceof SecretStoreUnavailableError) throw err;
throw new SecretStoreUnavailableError(
`Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
map[buildAccount(serverId, field)] = value;
try {
await this.writeMap(map);
} catch (err) {
throw new SecretStoreUnavailableError(
`Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}),
);
}
async delete(serverId: string, field: string): Promise<void> {
@ -419,17 +558,20 @@ export class FileSecretStore implements SecretStore {
*/
private async deleteWhere(match: (key: string) => boolean): Promise<void> {
try {
await this.serialize(async () => {
const map = await this.readMap();
if (!map) return;
const keys = Object.keys(map).filter(match);
if (keys.length === 0) return;
for (const key of keys) delete map[key];
// An emptied map keeps the file rather than unlinking it: its presence
// is what records which encryption mode this install settled on, so a
// later write can't silently switch modes on the user.
await this.writeMap(map);
});
await this.serialize(() =>
this.withFileLock(async () => {
const map = await this.readMap();
if (!map) return;
const keys = Object.keys(map).filter(match);
if (keys.length === 0) return;
for (const key of keys) delete map[key];
// An emptied map keeps the file rather than unlinking it: its
// presence is what records which encryption mode this install
// settled on, so a later write can't silently switch modes on the
// user.
await this.writeMap(map);
}),
);
} catch {
// Intentionally silent — see the doc comment above.
}
@ -450,11 +592,36 @@ export class FileSecretStore implements SecretStore {
*/
export async function tightenSecretFilePermissions(
filePath: string,
): Promise<void> {
): Promise<SecretFilePermissionState> {
try {
await fs.chmod(filePath, 0o600);
} catch {
// Non-existent (the common case — nothing written yet), read-only mount,
// or a platform without POSIX modes. Nothing actionable here.
// or a platform without POSIX modes. The verification below decides
// whether that mattered.
}
// Verify rather than assume. The chmod above is best-effort, but the UI and
// the README both state the file is 0600 — so a file we failed to tighten
// (owned by another user, on a read-only mount) must be *reported*, not
// quietly described as protected. This is the one place that can tell the
// difference, because it is the only one that looks at the file.
try {
const mode = (await fs.stat(filePath)).mode & 0o777;
if (mode === 0o600) return { state: "ok" };
return { state: "loose", mode };
} catch {
// No file yet — the common first run. `writeStoreFile` creates it 0600,
// so there is nothing to warn about.
return { state: "absent" };
}
}
/**
* Outcome of {@link tightenSecretFilePermissions}: the file is `0600`
* (`ok`), does not exist yet (`absent`), or exists with a wider mode we
* could not fix (`loose`) — the only case anyone needs to hear about.
*/
export type SecretFilePermissionState =
| { state: "ok" }
| { state: "absent" }
| { state: "loose"; mode: number };

View file

@ -145,44 +145,92 @@ export function isContainer(): boolean {
}
/**
* Is `dir` — or the nearest existing ancestor of it — a mount point?
* Nearest ancestor of `dir` that actually exists.
*
* The test is `st_dev`: a mount is exactly the case where a directory sits
* on a different device from its parent. That is what makes this work
* regardless of *what* was mounted (named volume, bind mount, tmpfs) and
* without parsing `/proc/mounts`, whose format and container-namespace
* visibility vary.
* 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).
*
* It walks to the nearest existing ancestor because the directory usually
* does not exist yet on a first run — `~/.mcp-inspector` is created on
* demand. A volume mounted *at* that path exists by definition, so the
* common positive case is found immediately; the walk is what keeps the
* common negative case from being answered by a missing-file error.
* Field 5 of each line is the mount point, with spaces escaped as `\040`.
* `/` is dropped: in a container that is the writable layer, which is
* exactly what the durability question is asking *against*.
*/
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((point) => point.replace(/\\040/g, " "))
.filter((point) => point !== "/");
} catch {
return null;
}
}
/**
* Does `dir` live on something mounted over the root filesystem?
*
* `/` is its own parent and is trivially a "mount", so the loop stops
* there and reports false: the container's root filesystem is the writable
* layer this whole check exists to distinguish *against*.
* 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 {
let current = path.resolve(dir);
// Walk up to the first path that exists.
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 false;
current = parent;
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; // reached `/`
return fsSync.statSync(current).dev !== fsSync.statSync(parent).dev;
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. "Not a mount" keeps the fallback on the conservative side
// (memory in a container), which never loses a secret it promised to keep.
// meaningful.
return false;
}
}
@ -207,32 +255,54 @@ async function buildStore(
}
const filePath = defaultSecretFilePath();
const store = new FileSecretStore({ filePath });
// Repair a loosened mode on a pre-existing file before anything reads or
// writes it. Fire-and-forget: it is a best-effort tightening (it swallows
// every failure), and blocking selection on a chmod would make an
// unwritable file a startup problem rather than a `set`-time one.
void tightenSecretFilePermissions(filePath);
// Report the FILE, not the intent. `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 footer must never 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.
// 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";
return {
store,
info: {
kind,
reason,
path: filePath,
plaintext,
// 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,
},
kind,
reason,
path: filePath,
plaintext,
// 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,
};
}
@ -305,9 +375,28 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> {
return resolved;
}
/** The active store's descriptor — for the banner and `GET /api/config`. */
/**
* 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> {
return (await resolveSecretStore()).info;
const { store, info } = await resolveSecretStore();
if (info.kind !== "file" || !(store instanceof FileSecretStore)) return info;
return describeFileStore(
store,
store.filePath,
"file",
info.reason,
info.detail,
);
}
/**