Stop granting an unscoped host-file read, and make a refused credential scrub recoverable

`core:default` was an alias for nine core plugins' default sets, and one of
them — `core:image:default` — carries `allow-from-path`, whose handler is a
bare `std::fs::read(path)` with no scope mechanism at all. Nothing imports
`@tauri-apps/api/image`, so the plugin is dropped rather than scoped; there is
nothing to scope it with. The capability file now enumerates what `app/src`
actually invokes, which is `core:event`'s listen/unlisten and nothing else from
core — every emit in this app originates in Rust. `core:menu`, `core:tray`,
`core:window`, `core:path`, `core:resources` and the three dead `dialog:`
grants go with it. `core:webview:allow-internal-toggle-devtools` stays because
Tauri's own injected debug script calls it; both it and the command behind it
are `cfg(any(debug_assertions, feature = "devtools"))`, so it is absent from a
release bundle. Verified empirically: an unknown identifier fails the build, so
every identifier kept is real and the regenerated `gen/schemas/capabilities.json`
carries the opener scope verbatim rather than silently dropping it.

`opener:allow-open-url` cannot be host-narrowed — the terminal opens links
Claude printed inside the container — so what it does and does not buy is
recorded instead, including the verified fact that each scope entry's `app`
defaults to `Application::Default`, which matches only `with == None` and
therefore refuses `openUrl(url, "/bin/sh")`.

`clear_claude_token` deleted the keychain entry first and swept the snapshot
images second. The sweep runs once and skips a project another operation holds,
the deleted entry made `has_claude_token` false, and Revoke rendered only while
a token was stored — so a project that happened to be starting during a revoke
kept a live ~1-year OAuth token in its snapshot's `Config.Env` permanently,
with Reset (which destroys both volumes) as the only remaining remedy. The
sweep now runs first, so a crash mid-revoke leaves the app still saying
"authenticated" with the same button still able to finish; a busy project is
reported as `snapshots_skipped` rather than folded in with images that genuinely
cannot be rewritten; and the panel keeps a retry visible independent of token
status, plus offers the sweep outright when nothing is stored, because a
snapshot committed by an older build carries the token either way. The retry is
the same command — it is idempotent, and the images are the durable record.

Also: `openai-compatible-api-key` was written but never deleted, so it outlived
its project. The key list is now the single definition and an unlisted key is
refused outright, so the writer cannot get ahead of the deleter again.

`store_or_clear_project_secret` lands here unused on purpose: the editors send
a blanked field as `null` and `store_secrets_for_project` skips `None`, so
clearing a secret through the UI is impossible today. Its one call site is in
`commands/project_commands.rs`, which belongs to another change in this round.

No `devCsp` was added. `tauri dev` loads the main document straight from Vite,
and Tauri only attaches a CSP to documents it serves itself — the dev server is
proxied through `tauri://` only when `PROXY_DEV_SERVER`, which is
`cfg!(all(dev, mobile))`. A `devCsp` here would be inert config that reads as
protection. The reasoning, and the one place that could set one, are recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 13:07:07 -07:00
co-authored by Claude Opus 5
parent 42ef1865cc
commit e70a40507c
6 changed files with 726 additions and 84 deletions
@@ -189,4 +189,115 @@ describe("SharedAuthSettings", () => {
expect(toast.kind).toBe("success");
expect(toast.message).toBe("Shared Claude token removed from the keychain.");
});
// ── A skipped scrub is not a success, and must stay retryable ────────────
// `scrub_secrets_from_snapshots` refuses a project another operation holds
// rather than racing its `:latest` tag. That leaves a live ~1-year token in
// the image, so it can be neither folded into the success message nor
// described as a permanent failure whose remedy is Reset.
it("reports a skipped snapshot as an incomplete revocation, not a success", async () => {
const toast = await revoke({
snapshots_skipped: [
"triple-c-snapshot-p1:latest: This project's container is being started or recreated. Wait for it to finish before removing a credential from its snapshot.",
],
});
expect(toast.kind).toBe("error");
expect(toast.message).toMatch(/still in 1 snapshot image/i);
expect(toast.detail).toMatch(/run the\s+cleanup again/i);
// The wrong advice for a transient refusal.
expect(toast.detail).not.toMatch(/Reset/i);
});
it("keeps a retry available after the revoke has cleared the keychain", async () => {
projects = [running()];
// Stored when the panel mounts, gone after the revoke — which is exactly
// the state that used to remove the only button able to finish the job.
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
clearClaudeToken.mockResolvedValue({
snapshots_scrubbed: [],
snapshots_failed: [],
snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"],
snapshots_superseded: [],
docker_unavailable: null,
});
render(<SharedAuthSettings />);
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
const retry = await screen.findByTestId("shared-auth-retry");
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(),
);
expect(screen.getByTestId("shared-auth-leftover")).toHaveTextContent(
/still readable/i,
);
expect(retry).toBeEnabled();
});
it("clears the warning when a retry finally finishes the job", async () => {
projects = [running()];
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
clearClaudeToken.mockResolvedValueOnce({
snapshots_scrubbed: [],
snapshots_failed: [],
snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"],
snapshots_superseded: [],
docker_unavailable: null,
});
render(<SharedAuthSettings />);
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
const retry = await screen.findByTestId("shared-auth-retry");
clearClaudeToken.mockResolvedValueOnce({
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
snapshots_failed: [],
snapshots_skipped: [],
snapshots_superseded: [],
docker_unavailable: null,
});
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument(),
);
expect(clearClaudeToken).toHaveBeenCalledTimes(2);
const toast = useAppState.getState().toasts.at(-1)!;
expect(toast.kind).toBe("success");
expect(toast.message).toMatch(/cleared from 1 snapshot image/i);
});
it("offers a snapshot sweep even when no token is stored", async () => {
// Snapshots committed by an older build carry the token whether or not
// anything is in the keychain today, so the sweep cannot be gated on it.
projects = [running()];
hasClaudeToken.mockResolvedValue(false);
clearClaudeToken.mockResolvedValue({
snapshots_scrubbed: [],
snapshots_failed: [],
snapshots_skipped: [],
snapshots_superseded: [],
docker_unavailable: null,
});
render(<SharedAuthSettings />);
const sweep = await screen.findByTestId("shared-auth-sweep");
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
fireEvent.click(sweep);
await waitFor(() => expect(clearClaudeToken).toHaveBeenCalled());
const toast = useAppState.getState().toasts.at(-1)!;
expect(toast.kind).toBe("success");
expect(toast.message).toBe("No snapshot image is holding the token.");
});
it("tolerates a backend that does not report skipped snapshots", async () => {
// `snapshots_skipped` is newer than the rest of the payload; its absence
// must read as "none", never as undefined reaching the UI.
const toast = await revoke({ snapshots_scrubbed: ["triple-c-snapshot-p1:latest"] });
expect(toast.kind).toBe("success");
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument();
});
});