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>
48 lines
2.3 KiB
TypeScript
48 lines
2.3 KiB
TypeScript
import { Group } from "@mantine/core";
|
|
|
|
export const ThemeGroup = Group.extend({
|
|
// `sectionHeader` styles a Group as a collapsible-section header "pleat". It
|
|
// shares the `.filter-toggle` treatment used by the FilterToggleButton: a thin
|
|
// outline on hover (rather than a background fill) so hover stays visually
|
|
// distinct from the active state. The active (open) background is passed
|
|
// per-instance via the `bg` prop. The border-radius and the reserved
|
|
// transparent border both come from `.filter-toggle` in App.css — the border
|
|
// must NOT be set here as an inline style, since inline styles outrank the
|
|
// stylesheet `:hover` rule (see #1460).
|
|
classNames: (_theme, props) => {
|
|
if (props.variant === "sectionHeader") return { root: "filter-toggle" };
|
|
return {};
|
|
},
|
|
styles: (_theme, props) => {
|
|
// `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.
|
|
//
|
|
// `position: sticky` is the load-bearing part, and it is the mirror of how
|
|
// Mantine pins the modal header. `Modal.Content` wraps *all* of its
|
|
// children — header, body, and this band — in one scroll area sized to the
|
|
// viewport, so a plain block at the end simply scrolls off the bottom with
|
|
// the body (measured: at 900px tall the band landed 140px below the fold).
|
|
// A footer the user has to scroll to find is not the permanent statement
|
|
// 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.
|
|
// 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",
|
|
bottom: 0,
|
|
zIndex: 1000,
|
|
backgroundColor: "var(--mantine-color-body)",
|
|
borderTop: "1px solid var(--inspector-border-default)",
|
|
flexShrink: 0,
|
|
},
|
|
};
|
|
}
|
|
return { root: {} };
|
|
},
|
|
});
|