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

@ -134,4 +134,43 @@ describe("SecretStorageFooter", () => {
expect(band).not.toHaveTextContent("Owner-only");
expect(band).toHaveAttribute("data-tone", "warn");
});
it("says an unreadable file is unreadable, not encrypted", async () => {
// The descriptor omits `plaintext` here, and the band must not read a
// default out of that absence.
renderWithMantine(
<SecretStorageFooter
info={{
kind: "file",
reason: "fallback",
durable: true,
path: "/home/node/.mcp-inspector/secrets.json",
encryptionUnknown: "not valid JSON",
}}
/>,
);
const band = screen.getByTestId("secret-storage-footer");
expect(band).toHaveTextContent("Unreadable file.");
expect(band).toHaveTextContent("Saving a secret will fail.");
expect(band).not.toHaveTextContent("Encrypted file");
expect(band).toHaveAttribute("data-tone", "warn");
});
it("does not claim a loose-mode encrypted file exposes its secrets", async () => {
renderWithMantine(
<SecretStorageFooter
info={{
kind: "file",
reason: "fallback",
durable: true,
plaintext: false,
path: "/home/node/.mcp-inspector/secrets.json",
looseMode: 0o644,
}}
/>,
);
const band = screen.getByTestId("secret-storage-footer");
expect(band).toHaveTextContent("Mode 0644 — not owner-only.");
expect(band).toHaveAttribute("data-tone", "warn");
});
});

View file

@ -159,6 +159,11 @@ function footerMessage(info: SecretStorageInfo): string {
? `${secretStorageLabel(info)}: ${caveat}`
: secretStorageLabel(info);
}
// "Unreadable" rather than guessing a mode: the envelope could not be
// parsed, so neither "Plaintext" nor "Encrypted" is a claim we can make.
if (info.encryptionUnknown !== undefined) {
return "Unreadable file. Saving a secret will fail.";
}
const encryption = info.plaintext ? "Plaintext" : "Encrypted";
const permissions = filePermissionsSentence(info);
return `${encryption} file. ${permissions}`;
@ -174,7 +179,11 @@ function footerMessage(info: SecretStorageInfo): string {
function footerTooltip(info: SecretStorageInfo): string | undefined {
if (info.kind !== "file" || !info.path) return undefined;
const parts: string[] = [];
if (info.plaintext) {
if (info.encryptionUnknown !== undefined) {
parts.push(
`The file could not be read (${info.encryptionUnknown}), so its contents are unknown and saving a secret will fail rather than overwrite it.`,
);
} else if (info.plaintext) {
// Advice that can actually clear the condition: someone who has already
// set the passphrase is waiting on the next write, not on themselves.
parts.push(
@ -184,8 +193,13 @@ function footerTooltip(info: SecretStorageInfo): string | undefined {
);
}
if (info.looseMode !== undefined) {
// Encryption-aware: a reader of an encrypted file gets ciphertext, not
// secrets. Claiming otherwise overstates the exposure, and a warning
// that cries wolf is discounted on the occasion it is literally true.
parts.push(
`Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
info.plaintext === false
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
);
} else if (info.permissionsUnknown !== undefined) {
parts.push(