Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/chat/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 23 additions & 6 deletions apps/web/src/pages/worker-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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({
Expand All @@ -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.
Expand Down Expand Up @@ -201,6 +217,7 @@ function PermissionsCard({
<CardTitle>Permissions</CardTitle>
<CardDescription>
What it may do without asking. Workbench grants can narrow these, never widen them.
Permissions carry over when you save new instructions.
</CardDescription>
{query.isPending ? (
<p className="mt-3 text-[13px] text-(--ink-3)">Loading permissions…</p>
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/worker-grants-carry.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
Loading