From c0b86f70bfecd3ba98ef86a3166f54b4e42dd007 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 07:49:04 -0700 Subject: [PATCH] fix(web): tool permissions survive a redeploy, and the approval copy says what it means (CL-9613) --- apps/web/src/chat/strings.ts | 4 +- apps/web/src/pages/worker-page.tsx | 29 +++++++--- apps/web/src/worker-grants-carry.ts | 83 +++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/worker-grants-carry.ts diff --git a/apps/web/src/chat/strings.ts b/apps/web/src/chat/strings.ts index 5465a4f08..8c39b2fa8 100644 --- a/apps/web/src/chat/strings.ts +++ b/apps/web/src/chat/strings.ts @@ -141,10 +141,10 @@ export const CHAT_STRINGS = { blockApproveAction: "Approve", blockDenyAction: "Deny", blockApproveAllowOnce: "Allow once", - blockApproveAlways: "Always allow here", + blockApproveAlways: "Always allow for this worker", blockApproveAskEvery: "Ask every time", blockApproveResolvedOnce: "Allowed once", - blockApproveResolvedAlways: "Always allowed in this workbench", + blockApproveResolvedAlways: "Always allowed for this worker", blockApproveResolvedAskEvery: "Allowed once, will ask every time", blockApproveResolvedDenied: "Denied", blockFormSubmit: "Submit", diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx index 100def6db..48e00c835 100644 --- a/apps/web/src/pages/worker-page.tsx +++ b/apps/web/src/pages/worker-page.tsx @@ -21,6 +21,7 @@ import { } from "../settings/tenancy-api"; import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy"; import { readAgentMcpHandles, readAgentSource } from "../agent-source-read"; +import { applyGrantSnapshot, readGrantSnapshot, saveGrantSnapshot } from "../worker-grants-carry"; import { useBench } from "../bench-context"; import { Link } from "../navigation"; import { tenantKeys } from "../query-client"; @@ -60,6 +61,10 @@ function InstructionsCard({ const slug = agentSlugFromSourceAssetName(agent.assetName); if (slug === null) throw new Error(`${agent.assetName} is not an editable worker`); const mcpHandles = await readAgentMcpHandles(tenantId, agent.id, agent.assetName); + const current = await readWorkerToolGrants(tenantId, agent); + if (current !== null) { + saveGrantSnapshot(tenantId, agent.assetName, current.principalId, current.tools); + } await deployAgentSource({ tenantId, input: { name: agent.name, systemPrompt, slug, mcpHandles }, @@ -149,13 +154,17 @@ async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) { (candidate) => candidate.kind === "workflow" && candidate.refId === run?.id, ); if (principal === undefined) return null; - const grants = await listGrants(tenantId, { principalId: principal.id }); - return { - principalId: principal.id, - tools: grants + const read = async () => { + const grants = await listGrants(tenantId, { principalId: principal.id }); + return grants .filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke") - .sort((a, b) => a.resource.localeCompare(b.resource)), + .sort((a, b) => a.resource.localeCompare(b.resource)); }; + let tools = await read(); + if (await applyGrantSnapshot(tenantId, agent.assetName, principal.id, tools)) { + tools = await read(); + } + return { principalId: principal.id, tools }; } function PermissionsCard({ @@ -167,7 +176,14 @@ function PermissionsCard({ }) { const queryClient = useQueryClient(); const key = [...tenantKeys.grants(tenantId), "worker", agent.id] as const; - const query = useQuery({ queryKey: key, queryFn: () => readWorkerToolGrants(tenantId, agent) }); + // While a redeploy's permissions are waiting for the new run to start, + // keep looking for its principal. + const carrying = readGrantSnapshot(tenantId, agent.assetName) !== null; + const query = useQuery({ + queryKey: key, + queryFn: () => readWorkerToolGrants(tenantId, agent), + refetchInterval: carrying ? 5000 : false, + }); // Create the new grant before revoking the old one so a failure never // leaves the tool ungoverned. @@ -201,6 +217,7 @@ function PermissionsCard({ Permissions What it may do without asking. Workbench grants can narrow these, never widen them. + Permissions carry over when you save new instructions. {query.isPending ? (

Loading permissions…

diff --git a/apps/web/src/worker-grants-carry.ts b/apps/web/src/worker-grants-carry.ts new file mode 100644 index 000000000..0dd8876dc --- /dev/null +++ b/apps/web/src/worker-grants-carry.ts @@ -0,0 +1,83 @@ +// A redeploy mints a new run, and runtime tool grants live on the run's +// principal, so non-default effects are snapshotted before the redeploy and +// re-applied once the new run's principal exists. + +import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; +import type { GrantEffect } from "@intx/types"; + +import { createGrant, revokeGrant, type Grant } from "./settings/tenancy-api"; + +const TOOL_PREFIX = "tool:"; + +const Snapshot = type({ + fromPrincipalId: "string", + effects: type({ resource: "string", effect: "'allow' | 'deny'" }).array(), +}); +export type GrantSnapshot = typeof Snapshot.infer; + +const storageKey = (tenantId: string, assetName: string) => + `workbench:worker-grants:${tenantId}:${assetName}`; + +export function readGrantSnapshot(tenantId: string, assetName: string): GrantSnapshot | null { + try { + const raw = localStorage.getItem(storageKey(tenantId, assetName)); + if (raw === null) return null; + const parsed = Snapshot(JSON.parse(raw)); + return parsed instanceof type.errors ? null : parsed; + } catch (cause) { + reportError(cause, { operation: "worker_grants_snapshot_read", tenantId }); + return null; + } +} + +function writeGrantSnapshot(tenantId: string, assetName: string, snapshot: GrantSnapshot | null) { + try { + if (snapshot === null) localStorage.removeItem(storageKey(tenantId, assetName)); + else localStorage.setItem(storageKey(tenantId, assetName), JSON.stringify(snapshot)); + } catch (cause) { + reportError(cause, { operation: "worker_grants_snapshot_write", tenantId }); + } +} + +/** Stock grants default to "ask"; only allow/deny differ from the default. */ +export function saveGrantSnapshot( + tenantId: string, + assetName: string, + principalId: string, + tools: readonly Grant[], +) { + const effects = tools.flatMap((grant) => + grant.resource.startsWith(TOOL_PREFIX) && (grant.effect === "allow" || grant.effect === "deny") + ? [{ resource: grant.resource, effect: grant.effect }] + : [], + ); + if (effects.length === 0) return; + writeGrantSnapshot(tenantId, assetName, { fromPrincipalId: principalId, effects }); +} + +/** Re-applies the snapshot onto a new run principal (create-then-revoke), + * then clears it. Returns true when grants were applied. */ +export async function applyGrantSnapshot( + tenantId: string, + assetName: string, + principalId: string, + tools: readonly Grant[], +): Promise { + const snapshot = readGrantSnapshot(tenantId, assetName); + if (snapshot === null || snapshot.fromPrincipalId === principalId) return false; + for (const { resource, effect } of snapshot.effects) { + const current = tools.find((grant) => grant.resource === resource); + if (current === undefined || current.effect === effect) continue; + await createGrant(tenantId, { + principalId, + resource, + action: "invoke", + effect: effect satisfies GrantEffect, + origin: "creator", + }); + await revokeGrant(tenantId, current.id); + } + writeGrantSnapshot(tenantId, assetName, null); + return true; +}