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:
parent
1aa1f5a0ca
commit
fdeffa1f00
2 changed files with 489 additions and 29 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue