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

@ -121,6 +121,38 @@ describe("secretStorageCaveat", () => {
expect(secretStorageCaveat(loose)).toContain("0640");
});
it("does not overstate exposure for an encrypted file with a loose mode", () => {
// A reader of an encrypted file gets ciphertext, not secrets. Saying
// otherwise trains people to discount the warning on the occasion it is
// literally true.
const loose: SecretStorageInfo = { ...encryptedFile, looseMode: 0o644 };
const caveat = secretStorageCaveat(loose);
expect(caveat).toContain("0644");
expect(caveat).toContain("passphrase is then the only thing");
expect(caveat).not.toContain("can read the secrets in it");
});
it("keeps the blunt wording for a plaintext file with a loose mode", () => {
const loose: SecretStorageInfo = { ...plaintextFile, looseMode: 0o644 };
expect(secretStorageCaveat(loose)).toContain("can read the secrets in it");
});
it("says a file it could not read is unreadable, and warns about the next save", () => {
const unreadable: SecretStorageInfo = {
kind: "file",
reason: "fallback",
durable: true,
path: "/x/secrets.json",
encryptionUnknown: "not valid JSON",
};
// Neither "encrypted" nor "unencrypted" is a claim we can make here.
expect(secretStorageLabel(unreadable)).toBe("File (unreadable)");
expect(secretStorageTone(unreadable)).toBe("warn");
expect(secretStorageCaveat(unreadable)).toMatch(
/could not be read \(not valid JSON\)\. Saving a secret will fail/,
);
});
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

@ -296,6 +296,26 @@ describe("useInitialConfig", () => {
expect(result.current.secretStorage).toEqual(info);
});
it("passes through a descriptor that reports an unreadable file", async () => {
// `plaintext` is legitimately absent here; the guard must accept that
// shape rather than requiring a field the backend cannot honestly set.
const info = {
kind: "file",
reason: "fallback",
durable: true,
path: "/home/node/.mcp-inspector/secrets.json",
encryptionUnknown: "not valid JSON",
};
const fetchFn = vi
.fn()
.mockResolvedValue(jsonResponse({ secretStorage: info }));
const { result } = renderHook(() =>
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.secretStorage).toEqual(info);
});
it("is undefined on a backend that omits the field", async () => {
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
const { result } = renderHook(() =>
@ -355,6 +375,33 @@ describe("useInitialConfig", () => {
looseMode: "644",
},
],
[
// Neither half of the encryption question answered.
"a file descriptor with neither plaintext nor encryptionUnknown",
{ kind: "file", reason: "fallback", durable: true, path: "/x" },
],
[
// Both answered, which is contradictory — the state was either read
// or it wasn't.
"a file descriptor with both plaintext and encryptionUnknown",
{
kind: "file",
reason: "fallback",
durable: true,
path: "/x",
plaintext: true,
encryptionUnknown: "not valid JSON",
},
],
[
"encryptionUnknown on a memory descriptor",
{
kind: "memory",
reason: "fallback",
durable: false,
encryptionUnknown: "not valid JSON",
},
],
[
"a non-string permissionsUnknown",
{