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

Four findings, all suppressed, all real. Three are the same mistake in
different places: a state that was never established being reported as a
confident answer.

- **`readOnDiskEncryption` conflated "no file" with "unreadable file".**
  Both answered `null`, and the descriptor falls back to the configured
  write policy on `null` — so a passphrase-configured install reported
  "File (encrypted)" about a file whose envelope had never parsed, and
  which `set` was in fact about to refuse rather than overwrite. It now
  returns a four-state result, and only `absent` falls back to the policy
  (honestly: the first write really will create it that way). `unreadable`
  produces `encryptionUnknown` on the descriptor, `plaintext` is omitted
  entirely so nothing can read a default out of it, and the label, caveat,
  tone and footer all carry "unreadable" rather than guessing a mode.

- **The loose-mode warning overstated exposure for an encrypted file.**
  "Anyone who can read it can read the secrets in it" is false when the
  file is ciphertext — a reader gets the bytes and still needs the
  passphrase. Both the caveat and the footer tooltip now branch on
  encryption: the passphrase becomes the only protection, which is the
  real consequence. Overstating it is not harmless, because a warning that
  cries wolf is discounted on the occasion it is literally true.

- **The keychain hand-off could invert its own keychain-wins rule.**
  `SecretStore.get` answers `null` both for a missing entry and for a
  keychain it could not read, and the migration *writes* on `null` — so a
  transient read failure would overwrite a newer keychain value with the
  older file copy. Added an optional `getStrict` (throws instead of
  swallowing) implemented by `KeyringSecretStore`, with
  `secretStoreGetStrict` falling back to `get` for stores whose reads
  cannot fail. A throw now aborts the hand-off with the file left in
  place, so the next run retries.

Writing the tests turned up one more, in code the review had passed:
`parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is
a *wildcard* asking about every process this user can signal — it answers
"alive" almost always, which would have made a lock with a corrupt stamp
permanently unstealable. Only a positive pid is usable now; anything else
is treated as no pid at all and the mtime decides.

Tests: the four envelope states kept distinct; the descriptor refusing to
guess, with the genuine no-file case as the control; strict-read abort
without a write and with the file preserved, plus a store whose `get` and
`getStrict` disagree so the test proves which one the migration consults;
encryption-aware wording asserted in both directions in the caveat and the
band; three unusable owner stamps and a wildcard pid; and the descriptor
shapes that must now be rejected — neither half of the encryption question
answered, or both. Full `npm run ci` green — 5,750 tests under the
coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 14:40:29 -04:00
commit fdeffa1f00
2 changed files with 489 additions and 29 deletions

View file

@ -484,6 +484,33 @@ describe("tightenSecretFilePermissions", () => {
}
});
it("names an errno-less stat failure rather than printing undefined", async () => {
vi.resetModules();
vi.doMock("node:fs/promises", async () => {
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
// No `code` property — a rejection shaped unlike an errno error.
return {
...actual,
default: actual,
stat: vi.fn(() => Promise.reject(new Error("something odd"))),
};
});
try {
const mod =
await import("@inspector/core/auth/node/file-secret-store.js");
expect(await mod.readSecretFilePermissions(filePath())).toEqual({
state: "unknown",
detail: "unknown error",
});
} finally {
vi.doUnmock("node:fs/promises");
vi.resetModules();
}
});
it("readSecretFilePermissions reports the mode without changing it", async () => {
// The descriptor is rebuilt on every GET /api/config, so the function it
// calls must not chmod — once per page load, on a file another process
@ -560,25 +587,40 @@ describe("readOnDiskEncryption", () => {
passphrase: "hunter2",
});
expect(withKey.encrypted).toBe(true);
expect(await withKey.readOnDiskEncryption()).toBe("none");
expect(await withKey.readOnDiskEncryption()).toEqual({
state: "plaintext",
});
await withKey.set("alpha", "env:A", "1");
expect(await withKey.readOnDiskEncryption()).toBe("aes-256-gcm");
expect(await withKey.readOnDiskEncryption()).toEqual({
state: "encrypted",
});
});
it("answers null for an absent, unparseable, or unrecognized file", async () => {
it("keeps 'no file yet' apart from 'there is a file and we cannot read it'", async () => {
// Collapsing these is what let a passphrase-configured install claim
// "Encrypted file" about a corrupt file `set` was about to refuse: the
// descriptor falls back to the write policy on `absent`, which is only
// honest when the first write really will create the file that way.
const store = new FileSecretStore({ filePath: filePath() });
expect(await store.readOnDiskEncryption()).toBe(null);
expect(await store.readOnDiskEncryption()).toEqual({ state: "absent" });
await fs.writeFile(filePath(), "{not json", "utf-8");
expect(await store.readOnDiskEncryption()).toBe(null);
expect(await store.readOnDiskEncryption()).toEqual({
state: "unreadable",
detail: "not valid JSON",
});
await fs.writeFile(
filePath(),
JSON.stringify({ version: 1, encryption: "rot13" }),
"utf-8",
);
expect(await store.readOnDiskEncryption()).toBe(null);
const unsupported = await store.readOnDiskEncryption();
expect(unsupported.state).toBe("unreadable");
expect(
unsupported.state === "unreadable" ? unsupported.detail : "",
).toMatch(/unsupported envelope/);
});
it("needs no passphrase — it reads the envelope, never the payload", async () => {
@ -589,7 +631,9 @@ describe("readOnDiskEncryption", () => {
});
await writer.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
const keyless = new FileSecretStore({ filePath: filePath() });
expect(await keyless.readOnDiskEncryption()).toBe("aes-256-gcm");
expect(await keyless.readOnDiskEncryption()).toEqual({
state: "encrypted",
});
});
});
@ -884,6 +928,51 @@ describe("acquireLock", () => {
await release();
});
it.each([
["not JSON at all", "{ not json"],
["JSON that is not an object", '"just-a-string"'],
["an object with no token", JSON.stringify({ pid: 1, host: "h" })],
])("steals a stale lock whose owner stamp is %s", async (_label, stamp) => {
// An unusable stamp cannot answer the liveness question, so the mtime is
// all there is — and stealing on it is right, because the alternative is
// a lock nothing can ever break.
const target = path.join(tmpDir, `stamp-${_label.length}.json`);
const lockDir = `${target}.lock`;
await fs.mkdir(lockDir, { recursive: true });
await fs.writeFile(path.join(lockDir, "owner"), stamp, "utf-8");
const old = new Date(Date.now() - 60_000);
await fs.utimes(lockDir, old, old);
const release = await acquireLock(target, {
staleMs: 50,
timeoutMs: 2_000,
pollMs: 10,
});
await release();
});
it("steals a stale lock whose stamp carries no usable pid", async () => {
// A stamp with a non-numeric pid names no process to ask about, so it
// cannot vouch for the holder being alive.
const target = path.join(tmpDir, "nopid.json");
const lockDir = `${target}.lock`;
await fs.mkdir(lockDir, { recursive: true });
await fs.writeFile(
path.join(lockDir, "owner"),
JSON.stringify({ token: "t", pid: "not-a-number", host: os.hostname() }),
"utf-8",
);
const old = new Date(Date.now() - 60_000);
await fs.utimes(lockDir, old, old);
const release = await acquireLock(target, {
staleMs: 50,
timeoutMs: 2_000,
pollMs: 10,
});
await release();
});
it("steals a stale lock stamped by another machine", async () => {
// A shared filesystem: we cannot ask this host about that host's pid, so
// the mtime is all there is. Refusing would make a lock nothing can ever

View file

@ -634,6 +634,63 @@ describe("absorbFileSecretsIntoKeyring", () => {
expect(await keyring.get("srv", "oauthClientSecret")).toBe(null);
});
it("aborts rather than overwriting when the keychain read fails", async () => {
// `get` answers null for an unreachable keychain as well as a missing
// entry, and the hand-off *writes* on null — so a transient read failure
// would replace a newer keychain value with the older file copy, exactly
// inverting the keychain-wins rule. The strict read turns that into a
// throw, which leaves the file in place for the next run.
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(true);
let wrote = false;
const flaky: SecretStore = {
get: async () => null,
getStrict: async () => {
throw new Error("keychain temporarily unavailable");
},
set: async () => {
wrote = true;
},
delete: async () => {},
deleteAllForServer: async () => {},
};
await mod.absorbFileSecretsIntoKeyring(flaky);
expect(wrote).toBe(false);
expect(existsSync(filePath)).toBe(true);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("left in place"));
});
it("uses the strict read when the store offers one", async () => {
// And when it answers "present", the keychain still wins.
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(true);
let wrote = false;
const store: SecretStore = {
// Deliberately disagrees with `getStrict` so the test proves which one
// the migration consults.
get: async () => null,
getStrict: async () => "already-here",
set: async () => {
wrote = true;
},
delete: async () => {},
deleteAllForServer: async () => {},
};
await mod.absorbFileSecretsIntoKeyring(store);
expect(wrote).toBe(false);
expect(existsSync(filePath)).toBe(false);
});
it("is a no-op for a file that holds no entries", async () => {
// An empty map is not a hand-off. Deleting the file here would be
// harmless but pointless; the interesting part is that it does not warn
@ -688,6 +745,47 @@ describe("absorbFileSecretsIntoKeyring", () => {
});
});
describe("the descriptor refuses to guess an unreadable file's encryption", () => {
it("reports encryptionUnknown instead of falling back to the write policy", async () => {
// With a passphrase configured, the old fallback claimed "File
// (encrypted)" about a file whose envelope had never been parsed — and
// which `set` was about to refuse rather than overwrite.
const filePath = path.join(tmpDir, "secrets.json");
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
await fs.writeFile(filePath, "{ not json", "utf-8");
vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(false);
const info = await mod.getSecretStorageInfo();
expect(info.encryptionUnknown).toBe("not valid JSON");
// No half-answer left for a consumer to read a default out of.
expect(info.plaintext).toBeUndefined();
expect(info.pendingEncryption).toBeUndefined();
const { secretStorageLabel, secretStorageCaveat, secretStorageTone } =
await import("@inspector/core/auth/secret-storage-info.js");
expect(secretStorageLabel(info)).toBe("File (unreadable)");
expect(secretStorageTone(info)).toBe("warn");
expect(secretStorageCaveat(info)).toMatch(/Saving a secret will fail/);
});
it("still uses the write policy when there is genuinely no file", async () => {
// The control: `absent` is the one state where reporting the policy is
// honest, because the first write really will create it that way.
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "none.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.encryptionUnknown).toBeUndefined();
expect(info.plaintext).toBe(false);
});
});
describe("the descriptor reports a mode it could not verify", () => {
it("carries permissionsUnknown when the file exists but cannot be inspected", async () => {
// The third state. Without it an unverifiable mode fell into the same