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();
});
});
@@ -5,6 +5,7 @@ import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
import { selectClass } from "../ui/Field";
import ClaudeAuthModal from "./ClaudeAuthModal";
import { clearClaudeToken } from "../../lib/tauri-commands";
import type { ClearTokenOutcome } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects";
import { useAppState } from "../../store/appState";
import { authErrorMessage, useClaudeTokenStatus } from "../../hooks/useClaudeAuth";
@@ -37,6 +38,32 @@ const STATUS_DISPLAY: Record<
},
};
/**
* `clear_claude_token` reports three separate things about the snapshot images
* that `docker commit` baked the token into, and they need three different
* sentences. `snapshots_skipped` in particular is **not** a failure of the
* rewrite — the project was busy (starting, compacting, migrating) and its
* snapshot was never attempted, so the remedy is to run the sweep again, not
* to Reset the project and lose both its volumes.
*
* `snapshots_skipped` is read defensively: it is newer than `ClearTokenOutcome`
* in `lib/types.ts`, which another change in this round owns. Until that lands
* the field arrives over IPC but is not in the declared type, and an older
* backend would not send it at all.
*/
type RevokeOutcome = ClearTokenOutcome & { snapshots_skipped?: string[] };
const list = (values: string[] | undefined): string[] => values ?? [];
/** Whether a copy of the token is known — or suspected — to still be reachable. */
function needsAnotherPass(outcome: RevokeOutcome): boolean {
return (
list(outcome.snapshots_failed).length > 0 ||
list(outcome.snapshots_skipped).length > 0 ||
Boolean(outcome.docker_unavailable)
);
}
/**
* Host-level control for the one long-lived Claude Code token shared by every
* project. Acquisition needs a running container to run the CLI in, so the
@@ -50,7 +77,14 @@ export default function SharedAuthSettings() {
const [pickedId, setPickedId] = useState<string | null>(null);
const [authOpen, setAuthOpen] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false);
const [revoking, setRevoking] = useState(false);
const [sweeping, setSweeping] = useState(false);
// What the last sweep could not finish. Held in its own state rather than
// derived from `status`, because that is exactly the bug this fixes: a
// revoke clears the keychain, `status` flips to "absent", and the button
// that could have retried the snapshot rewrite disappeared with it —
// leaving a live ~1-year token in an image and Reset as the only remedy.
const [leftover, setLeftover] = useState<RevokeOutcome | null>(null);
// `claude setup-token` runs inside a container, so only running projects can
// host the flow.
@@ -61,13 +95,27 @@ export default function SharedAuthSettings() {
const display = STATUS_DISPLAY[status];
const handleRevoke = async () => {
setRevoking(true);
/**
* Run `clear_claude_token`. It is deliberately the same command for both the
* first revoke and every retry: it sweeps the snapshot images first, deletes
* the keychain entry second, and treats a missing entry as success — so
* calling it again with nothing stored is a pure snapshot sweep, and the
* images themselves are the durable record of what is left to do.
*/
const runSweep = async (mode: "revoke" | "sweep") => {
setSweeping(true);
try {
const outcome = await clearClaudeToken();
const outcome = (await clearClaudeToken()) as RevokeOutcome;
setConfirmRevoke(false);
await refresh();
const failed = list(outcome.snapshots_failed);
const skipped = list(outcome.snapshots_skipped);
const scrubbed = list(outcome.snapshots_scrubbed);
const superseded = list(outcome.snapshots_superseded);
setLeftover(needsAnotherPass(outcome) ? outcome : null);
// The keychain entry is gone either way. What matters here is the copy of
// the token that `docker commit` baked into each project's snapshot
// image: that one outlives every container, and `docker image inspect`
@@ -77,29 +125,48 @@ export default function SharedAuthSettings() {
if (outcome.docker_unavailable) {
pushToast({
kind: "error",
message: "Token removed from the keychain, but snapshots were not checked.",
message:
mode === "revoke"
? "Token removed from the keychain, but snapshots were not checked."
: "Snapshot images were not checked.",
detail:
`Docker could not be reached (${outcome.docker_unavailable}), so any snapshot image ` +
"built before this version may still contain the token in its environment. " +
"Start Docker and revoke again to clear them.",
"Start Docker and run the cleanup again to clear them.",
});
} else if (outcome.snapshots_failed.length > 0) {
} else if (failed.length > 0) {
pushToast({
kind: "error",
message: "Token removed from the keychain, but it is still in some images.",
detail:
`${outcome.snapshots_failed.length} snapshot image(s) could not be rewritten and ` +
`${failed.length} snapshot image(s) could not be rewritten and ` +
"still contain the token, readable via `docker image inspect`. Reset those " +
`projects to remove the images. Details: ${outcome.snapshots_failed.join("; ")}`,
`projects to remove the images. Details: ${failed.join("; ")}` +
(skipped.length > 0
? ` A further ${skipped.length} image(s) were skipped because their projects ` +
"are busy; those can be cleared by running the cleanup again."
: ""),
});
} else if (outcome.snapshots_scrubbed.length > 0) {
} else if (skipped.length > 0) {
pushToast({
kind: "error",
message: `Token still in ${skipped.length} snapshot image(s) — those projects were busy.`,
detail:
"Nothing was rewritten for them, so the token is still readable via " +
"`docker image inspect`. Wait for the operation in progress to finish and run the " +
`cleanup again. Details: ${skipped.join("; ")}`,
});
} else if (scrubbed.length > 0) {
pushToast({
kind: "success",
message: `Shared Claude token removed, and cleared from ${outcome.snapshots_scrubbed.length} snapshot image(s).`,
message:
mode === "revoke"
? `Shared Claude token removed, and cleared from ${scrubbed.length} snapshot image(s).`
: `Token cleared from ${scrubbed.length} snapshot image(s).`,
detail:
outcome.snapshots_superseded.length > 0
superseded.length > 0
? "The pre-rewrite image layer for " +
`${outcome.snapshots_superseded.join(", ")} is still on disk because a ` +
`${superseded.join(", ")} is still on disk because a ` +
"container is running from it. It goes away once that project is restarted " +
"(which recreates the container) and Docker prunes the leftover."
: undefined,
@@ -107,23 +174,32 @@ export default function SharedAuthSettings() {
} else {
pushToast({
kind: "success",
message: "Shared Claude token removed from the keychain.",
message:
mode === "revoke"
? "Shared Claude token removed from the keychain."
: "No snapshot image is holding the token.",
});
}
} catch (e) {
pushToast({
kind: "error",
message: "Could not remove the shared Claude token.",
message:
mode === "revoke"
? "Could not remove the shared Claude token."
: "Could not clear the token from snapshot images.",
detail: authErrorMessage(
e,
"The OS keychain rejected the delete. The token may still be stored.",
),
});
} finally {
setRevoking(false);
setSweeping(false);
}
};
const leftoverFailed = list(leftover?.snapshots_failed);
const leftoverSkipped = list(leftover?.snapshots_skipped);
return (
<div className="space-y-3">
<div>
@@ -188,14 +264,77 @@ export default function SharedAuthSettings() {
<Button
size="md"
variant="danger"
disabled={revoking}
disabled={sweeping}
onClick={() => setConfirmRevoke(true)}
>
Revoke
</Button>
)}
{status === "absent" && (
// Not gated on a stored token, on purpose. A revoke that could not
// finish leaves the token in a snapshot image while the keychain
// entry — and therefore the Revoke button — is already gone, and a
// snapshot committed by an older build carries it whether or not
// anything is stored today. With nothing in the keychain the same
// command is a pure image sweep.
<Button
size="md"
variant="ghost"
disabled={sweeping}
data-testid="shared-auth-sweep"
onClick={() => void runSweep("sweep")}
>
{sweeping ? "Checking…" : "Check snapshot images"}
</Button>
)}
</div>
{leftover && (
<div
data-testid="shared-auth-leftover"
className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] p-2"
>
<StatusIndicator
tone="error"
label="Token still readable"
className="text-xs"
/>
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-snug">
{leftover.docker_unavailable
? `Docker could not be reached (${leftover.docker_unavailable}), so no snapshot image was checked.`
: null}
{leftoverSkipped.length > 0 ? (
<>
{leftoverSkipped.length} snapshot image(s) were skipped because their
projects were busy. Nothing was rewritten for them, so the token is
still readable with{" "}
<code className="font-mono">docker image inspect</code>. Running the
cleanup again once those projects are idle clears them.
</>
) : null}
{leftoverFailed.length > 0 ? (
<>
{" "}
{leftoverFailed.length} snapshot image(s) could not be rewritten:{" "}
{leftoverFailed.join("; ")}. If retrying does not help, Reset those
projects to remove the images.
</>
) : null}
</p>
<div className="mt-2">
<Button
size="md"
variant="secondary"
disabled={sweeping}
data-testid="shared-auth-retry"
onClick={() => void runSweep("sweep")}
>
{sweeping ? "Retrying…" : "Retry snapshot cleanup"}
</Button>
</div>
</div>
)}
{!host && (
<p
data-testid="shared-auth-no-container"
@@ -237,17 +376,17 @@ export default function SharedAuthSettings() {
size="md"
variant="ghost"
onClick={() => setConfirmRevoke(false)}
disabled={revoking}
disabled={sweeping}
>
Cancel
</Button>
<Button
size="md"
variant="danger"
disabled={revoking}
onClick={() => void handleRevoke()}
disabled={sweeping}
onClick={() => void runSweep("revoke")}
>
{revoking ? "Revoking…" : "Revoke token"}
{sweeping ? "Revoking…" : "Revoke token"}
</Button>
</>
}
@@ -260,11 +399,11 @@ export default function SharedAuthSettings() {
restarted.
</p>
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
Each project&rsquo;s snapshot image is also rewritten, because{" "}
Each project&rsquo;s snapshot image is rewritten first, because{" "}
<code className="font-mono">docker commit</code> copies the token into it
and an image outlives every container built from it. If any image
cannot be rewritten you will be told which, and the token stays readable
in it until that project is Reset.
and an image outlives every container built from it. A project that is
busy right now is skipped rather than rewritten unsafely &mdash; you will
be told which, and the cleanup can be run again from here afterwards.
</p>
</Modal>
)}