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

Six findings, all taken.

- The descriptor reported the *write policy*, not the file. Adding
  `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
  file flipped `store.encrypted` immediately, so the banner and footer
  claimed "File (encrypted)" while the existing secrets sat readable
  until the next `set`. `FileSecretStore.readOnDiskEncryption()` now
  reads the envelope (never the payload, so it needs no passphrase) and
  the descriptor is built from that. The transitional state gets its own
  `pendingEncryption` flag, which changes the advice without softening
  the verdict — "set a passphrase" is the wrong thing to tell someone who
  already has, so the caveat names the pending write instead.

- The secrets file ignored the configured storage directory. A container
  mounting only that directory was judged unmounted, fell back to
  `memory`, and lost secrets the user had arranged to keep.
  `defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable
  that already relocates OAuth tokens and `client.json` — between the
  explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector`
  default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no
  such variable exists.)

- `version` was never checked before the plaintext branch, so a
  `{ version: 2, encryption: "none" }` file was read and silently
  rewritten as version 1, discarding whatever the newer format added —
  the same destroy-to-satisfy-an-additive-request case the encrypted
  branch already refuses. Checked first now, so the answer no longer
  depends on which encryption mode the newer writer chose.

- The doc comment claimed a per-file salt made "a short, memorable value
  safe to use". It does not: the salt defeats precomputation, not
  guessing, and the scrypt cost is deliberately a few milliseconds
  because it runs on every read and write. Both the comment and the
  README now say to use a high-entropy value, generated rather than
  chosen.

- `usableSecretStorage` validated only `kind`, so a partial
  `{ kind: "file" }` was cast through and — because a missing `plaintext`
  is falsy — rendered as the quiet, neutral *encrypted* file. That is the
  confident half-answer the guard exists to reject, produced by the
  absence of information rather than by any claim the backend made.
  `reason`, `durable`, and each kind's own required fields are checked.

- The footer's inline `Tooltip` carried four static props; extracted to a
  `CaveatTooltip` constant per the `.withProps()` rule.

Tests cover each: the policy/file disagreement in both directions and the
no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against
`MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes
survive it) split from the unrecognized-cipher refusal it used to be
conflated with, `readOnDiskEncryption` including the keyless and
unparseable cases, five new rejected descriptor shapes, and the changed
caveat. Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 12:26:33 -04:00
commit 3452a63101
5 changed files with 411 additions and 75 deletions

View file

@ -66,6 +66,15 @@ const FooterDetail = Text.withProps({
flex: 1,
});
// `multiline` + a fixed width so the sentence wraps instead of forming a
// tooltip wider than the modal it explains. Only the `label` is dynamic.
const CaveatTooltip = Tooltip.withProps({
multiline: true,
w: 320,
position: "top",
withArrow: true,
});
const ICON_SIZE = 14;
/**
@ -129,11 +138,5 @@ export function SecretStorageFooter({ info }: SecretStorageFooterProps) {
const tip = [caveat, info.path].filter(Boolean).join(" ");
if (!tip) return band;
return (
// `multiline` + a width so the sentence wraps instead of forming a tooltip
// wider than the modal it explains.
<Tooltip label={tip} multiline w={320} position="top" withArrow>
{band}
</Tooltip>
);
return <CaveatTooltip label={tip}>{band}</CaveatTooltip>;
}

View file

@ -87,6 +87,22 @@ describe("secretStorageCaveat", () => {
expect(caveat).toContain("MCP_INSPECTOR_SECRET_KEY");
});
it("changes the advice once a passphrase is set but not yet applied", () => {
// Telling someone to set MCP_INSPECTOR_SECRET_KEY when they already have
// is advice that cannot clear the condition. The condition here is the
// pending write, so that is what the sentence names — while the verdict
// (still unencrypted, still a warning) stays exactly the same.
const pending: SecretStorageInfo = {
...plaintextFile,
pendingEncryption: true,
};
expect(secretStorageTone(pending)).toBe("warn");
expect(secretStorageLabel(pending)).toBe("File (unencrypted)");
const caveat = secretStorageCaveat(pending);
expect(caveat).toContain("next time a secret is saved");
expect(caveat).not.toContain("MCP_INSPECTOR_SECRET_KEY");
});
it("prefers the memory caveat when a memory store somehow carries the flag", () => {
// Defensive ordering: `plaintext` is meaningless for memory, and the
// durability loss is the more consequential of the two statements.

View file

@ -289,6 +289,27 @@ describe("useInitialConfig", () => {
["null", null],
["an unknown kind", { kind: "vault", durable: true }],
["a missing kind", { durable: true, path: "/x" }],
// The dangerous one: `kind` alone passed the old check, and a missing
// `plaintext` is falsy, so it rendered as the quiet *encrypted* file —
// the most misleading thing this footer can say, produced by the absence
// of information rather than by anything the backend claimed.
["a file descriptor with nothing but a kind", { kind: "file" }],
[
"a file descriptor missing its path",
{ kind: "file", reason: "fallback", durable: true, plaintext: false },
],
[
"a file descriptor missing plaintext",
{ kind: "file", reason: "fallback", durable: true, path: "/x" },
],
[
"a descriptor with an unknown reason",
{ kind: "keyring", reason: "vibes", durable: true },
],
[
"a descriptor with a non-boolean durable",
{ kind: "memory", reason: "fallback", durable: "no" },
],
])("rejects %s", async (_label, value) => {
const fetchFn = vi
.fn()

View file

@ -225,6 +225,31 @@ describe("FileSecretStore failure handling", () => {
);
});
it("refuses a file written by a newer format version", async () => {
// Readable as far as the plaintext branch is concerned, which is the trap:
// accepting it means the next write rewrites it as version 1 and silently
// drops whatever the newer format added. Same refusal the encrypted branch
// already gives, so the answer doesn't depend on which mode the newer
// writer happened to choose.
await fs.writeFile(
filePath(),
JSON.stringify({
version: 2,
encryption: "none",
secrets: { "a:b": "1" },
}),
"utf-8",
);
const store = new FileSecretStore({ filePath: filePath() });
expect(await store.get("a", "b")).toBe(null);
await expect(store.set("a", "b", "2")).rejects.toThrow(/version 2/);
// The bytes are still there — nothing was destroyed to answer the write.
const raw = JSON.parse(await fs.readFile(filePath(), "utf-8")) as {
version: number;
};
expect(raw.version).toBe(2);
});
it("delete stays silent on a file it cannot decrypt", async () => {
await writeEncryptedFixture();
const store = new FileSecretStore({
@ -246,15 +271,34 @@ describe("FileSecretStore failure handling", () => {
expect(await store.get("alpha", "env:A")).toBe(null);
});
it("set reports a file written in a format this build doesn't know", async () => {
it("set reports a file written by a newer format version", async () => {
// The forward-compat case: a newer Inspector's file, opened by an older
// one. Refusing beats truncating it to the entries we happen to parse.
// The version is checked before the encryption mode, so this is the
// message even though the cipher is also unrecognized — the version is
// the more actionable of the two ("upgrade"), and it is the reason the
// file is unreadable regardless of what the cipher turned out to be.
await fs.writeFile(
filePath(),
JSON.stringify({ version: 99, encryption: "chacha20-poly1305" }),
"utf-8",
);
const store = new FileSecretStore({ filePath: filePath() });
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
/format version 99/,
);
});
it("set reports an unrecognized cipher at a version it does understand", async () => {
// The other half: a file this build's version check accepts, whose
// encryption mode it has no code for. A different failure with different
// advice, so it must not collapse into the version message.
await fs.writeFile(
filePath(),
JSON.stringify({ version: 1, encryption: "chacha20-poly1305" }),
"utf-8",
);
const store = new FileSecretStore({ filePath: filePath() });
await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(
/unsupported format/,
);
@ -409,3 +453,48 @@ describe("tightenSecretFilePermissions", () => {
).resolves.toBeUndefined();
});
});
describe("readOnDiskEncryption", () => {
it("reports what the file is, not what the store would write", async () => {
// The whole reason it exists: these two disagree for a full session after
// a passphrase is added to an install that already has a plaintext file.
const plain = new FileSecretStore({ filePath: filePath() });
await plain.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
const withKey = new FileSecretStore({
filePath: filePath(),
passphrase: "hunter2",
});
expect(withKey.encrypted).toBe(true);
expect(await withKey.readOnDiskEncryption()).toBe("none");
await withKey.set("alpha", "env:A", "1");
expect(await withKey.readOnDiskEncryption()).toBe("aes-256-gcm");
});
it("answers null for an absent, unparseable, or unrecognized file", async () => {
const store = new FileSecretStore({ filePath: filePath() });
expect(await store.readOnDiskEncryption()).toBe(null);
await fs.writeFile(filePath(), "{not json", "utf-8");
expect(await store.readOnDiskEncryption()).toBe(null);
await fs.writeFile(
filePath(),
JSON.stringify({ version: 1, encryption: "rot13" }),
"utf-8",
);
expect(await store.readOnDiskEncryption()).toBe(null);
});
it("needs no passphrase — it reads the envelope, never the payload", async () => {
// A store whose key is wrong still has to be describable in the UI.
const writer = new FileSecretStore({
filePath: filePath(),
passphrase: "right-key",
});
await writer.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
const keyless = new FileSecretStore({ filePath: filePath() });
expect(await keyless.readOnDiskEncryption()).toBe("aes-256-gcm");
});
});

View file

@ -33,6 +33,7 @@ const ENV_KEYS = [
"MCP_INSPECTOR_SECRET_STORE",
"MCP_INSPECTOR_SECRET_FILE",
"MCP_INSPECTOR_SECRET_KEY",
"MCP_STORAGE_DIR",
"KUBERNETES_SERVICE_HOST",
];
@ -381,4 +382,86 @@ describe("defaultSecretFilePath", () => {
path.join(".mcp-inspector", "secrets.json"),
);
});
it("follows MCP_STORAGE_DIR when no explicit file is named", async () => {
// The variable that already relocates OAuth tokens and client.json. A
// container mounting only that directory must not be judged unmounted and
// demoted to `memory` — the user arranged for exactly this directory to
// survive.
process.env.MCP_STORAGE_DIR = tmpDir;
const mod = await loadWithProbe(true);
expect(mod.defaultSecretFilePath()).toBe(path.join(tmpDir, "secrets.json"));
});
it("lets an explicit MCP_INSPECTOR_SECRET_FILE outrank MCP_STORAGE_DIR", async () => {
// Naming the file outright is the more specific statement of the two.
process.env.MCP_STORAGE_DIR = tmpDir;
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "elsewhere.json");
const mod = await loadWithProbe(true);
expect(mod.defaultSecretFilePath()).toBe(
path.join(tmpDir, "elsewhere.json"),
);
});
});
describe("the descriptor reports the file, not the write policy", () => {
it("says plaintext while a pre-existing plaintext file is only *going* to be encrypted", async () => {
// The transitional state: the passphrase is set, so the next write will
// encrypt, but the bytes on disk are still readable. Reporting "File
// (encrypted)" here is the one false reassurance this subsystem exists to
// avoid.
const filePath = path.join(tmpDir, "secrets.json");
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
vi.spyOn(console, "warn").mockImplementation(() => {});
const plain = await loadWithProbe(false);
await plain.defaultSecretStore().set("alpha", "env:A", "1");
// Same file, now with a passphrase configured.
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
const withKey = await loadWithProbe(false);
const info = await withKey.getSecretStorageInfo();
expect(info.plaintext).toBe(true);
expect(info.pendingEncryption).toBe(true);
// And the advice changes with it: telling someone to set a passphrase
// they have already set is worse than saying nothing.
const { secretStorageCaveat, secretStorageLabel } =
await import("@inspector/core/auth/secret-storage-info.js");
expect(secretStorageLabel(info)).toBe("File (unencrypted)");
expect(secretStorageCaveat(info)).toMatch(/next time a secret is saved/);
});
it("says encrypted once the upgrading write has actually landed", async () => {
const filePath = path.join(tmpDir, "secrets.json");
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
vi.spyOn(console, "warn").mockImplementation(() => {});
const plain = await loadWithProbe(false);
await plain.defaultSecretStore().set("alpha", "env:A", "1");
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
const withKey = await loadWithProbe(false);
await withKey.defaultSecretStore().set("alpha", "env:B", "2");
const after = await loadWithProbe(false);
const info = await after.getSecretStorageInfo();
expect(info.plaintext).toBe(false);
expect(info.pendingEncryption).toBeUndefined();
});
it("falls back to the write policy when there is no file yet", async () => {
// Nothing on disk to describe, and the policy is then accurate: the first
// write creates the file in exactly that mode.
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "absent.json");
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(false);
const info = await mod.getSecretStorageInfo();
expect(info.plaintext).toBe(false);
expect(info.pendingEncryption).toBeUndefined();
});
});