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

Seven findings, all taken.

- **The descriptor went stale after the upgrading write.** Round 1 made it
  read the file instead of the policy, but it was still computed once and
  cached beside the selection — so the first `set` under a newly-set
  passphrase encrypted the file while `/api/config` kept serving "still
  unencrypted" for the rest of the session, describing bytes this very
  process had just changed. The *selection* stays cached (the banner, the
  payload, and the store doing the writing must agree on which store it
  is); the file descriptor is re-derived per call, which costs one read of
  a small file per config fetch.

- **The mount check asked the wrong question.** Comparing a directory's
  `st_dev` against its parent's detects a mount *at* that path, but a
  volume mounted at `/home/node` leaves `/home/node/.mcp-inspector` an
  ordinary subdirectory sharing its parent's device — so a durable
  container setup was judged unmounted and demoted to session-only
  `memory`. It now reads `/proc/self/mountinfo` and asks whether the
  directory is *under* any mount other than `/`, which also catches the
  same-device bind mount no `st_dev` test can see. Where mountinfo is
  unreadable (macOS, Windows, a `/proc`-less container) it falls back to
  the device comparison, now against `/` rather than the parent.

- **A decrypted payload was cast, not checked.** GCM proves the bytes are
  authentic, not that they parse to the right shape. An array passes
  `typeof === "object"`, takes the named assignment, and then
  `JSON.stringify` drops every named property of an array — so `set` would
  resolve having written a file without the secret it was handed. Silent
  loss on the one operation whose entire contract is that it does not lose
  things. Both branches now validate a non-array object of string values,
  and a shape refusal is deliberately not rethrown as a key mismatch,
  which would send the user to fix a working passphrase.

- **The read-modify-write cycle was only serialized in-process.** A
  durable file is shared state and a second Inspector on the same box (a
  CLI run beside a web session) could read the same map and overwrite the
  first one's save, both reporting success. Mutations now take a
  `mkdir`-based lock. A lock left by a killed process is stolen after 10s
  — one `docker kill` must not brick secret writes permanently, which
  would be a worse failure than the race. If the lock cannot be created at
  all, the write proceeds unlocked rather than denying a real user a real
  secret to prevent a race with a process that may not exist. Reads do not
  lock: `writeStoreFile` is atomic, so a reader sees the old file or the
  new one, never a torn one.

- **The permission repair was fire-and-forget while the docs promised
  0600.** A file owned by another user, or on a read-only mount, stayed
  exposed while the footer described it as protected. It now verifies the
  resulting mode and warns, naming the actual mode, when it could not be
  tightened.

- **`Group`'s theme carried a domain-named `secretStorageFooter`
  variant**, which AGENTS.md reserves for app-wide primitive styling.
  Renamed to the structural `stickyModalFooter`; the secret-storage
  meaning stays in the element that owns it.

- The PR description still claimed the salt made a short passphrase safe,
  contradicting the corrected comment and README. Updated.

Tests: both mount layouts that motivated the change (mount-at and
mount-above) plus the sibling-prefix, space-escaped, first-run, and
no-mountinfo cases; the array and non-string refusals on both the
plaintext and the authentic-ciphertext paths (the latter through a
hand-built fixture, since the store's own writer cannot produce that
state); lock exclusion, stale-lock stealing, the un-lockable fallback, and
two independent store instances racing; the permission report in all three
states; and the descriptor no longer going stale within one process. Full
`npm run ci` green — 5,688 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 12:51:14 -04:00
commit de7795ca54

View file

@ -14,7 +14,7 @@ export const ThemeGroup = Group.extend({
return {};
},
styles: (_theme, props) => {
// `secretStorageFooter` is the permanent band at the bottom of the
// `stickyModalFooter` is the permanent band at the bottom of the
// Client/Server Settings modals naming where secrets are stored (#1950).
// Flat properties, so they belong here rather than in App.css.
//
@ -27,7 +27,11 @@ export const ThemeGroup = Group.extend({
// this is supposed to be, so it sticks to the bottom edge instead, with
// the same opaque body background and z-index the sticky header uses so
// content passes underneath rather than through it.
if (props.variant === "secretStorageFooter") {
// Structural, not domain: this says "a band pinned to the bottom of a
// modal", which is all the theme layer should know. What the band *means*
// — that it names where your secrets go — belongs to `SecretStorageFooter`,
// the element that owns those semantics.
if (props.variant === "stickyModalFooter") {
return {
root: {
position: "sticky",