From 555c66cdf6651c28489ef419ce105707e0bced8a Mon Sep 17 00:00:00 2001 From: Tim Holm Date: Fri, 2 Oct 2026 19:08:26 +1000 Subject: [PATCH] feat: let agents ask for network access, which their pod decides request_network_access asks for a host the pod's sandbox can't reach, and the turn waits for an answer. Approvals can now come from built-in tools as well as connections, and who decides one follows from its tool: those who decide the pod's hosts decide this one, and allowing it adds the host for the pod's sandbox. A request for a host the workspace blocked is refused at once, without asking anyone, as is allowing one blocked while it waited. --- packages/contracts/src/threads.ts | 15 ++- packages/core/src/authorization/access.ts | 10 +- .../core/src/conversations/chats/chat-view.ts | 28 ++++-- .../src/conversations/chats/chats.test.ts | 16 ++- .../conversations/routines/routines.test.ts | 11 ++- .../core/src/conversations/thread-view.ts | 7 +- .../src/conversations/threads/tool-calls.ts | 2 + .../conversations/tools/approval-deciders.ts | 17 ++++ .../tools/network-access/tool.ts | 68 +++++++++++++ .../core/src/conversations/tools/sandbox.ts | 93 ++++++++++++++---- .../src/conversations/tools/sandbox/tools.ts | 5 +- .../turns/approvals/approved-calls.ts | 4 +- .../core/src/conversations/turns/controls.ts | 6 +- .../src/conversations/turns/repository.ts | 30 ++++-- .../turns/tool-calls/recorded.test.ts | 9 +- .../turns/tool-calls/recorded.ts | 8 +- .../turns/tool-calls/repository.test.ts | 61 ++++++++---- .../turns/tool-calls/repository.ts | 98 ++++++++++++------- .../core/src/conversations/turns/tools.ts | 39 ++++++-- .../conversations/turns/turn.steps.test.ts | 63 +++++++++++- .../src/conversations/turns/turn.steps.ts | 91 ++++++++++++----- .../core/src/sandboxes/sandbox-network.ts | 57 ++++++++++- packages/web/src/lib/tool-approvals.ts | 21 ++++ packages/web/src/lib/tool-names.ts | 3 + packages/web/src/screens/AgentChat.tsx | 2 +- packages/web/src/screens/ChatThreadPanel.tsx | 2 +- .../web/src/screens/ThreadConversation.tsx | 14 +-- .../src/screens/ToolApprovalCard.stories.tsx | 35 +++++++ packages/web/src/screens/ToolApprovalCard.tsx | 52 ++++++++-- .../src/screens/thread-conversation.test.tsx | 2 +- 30 files changed, 700 insertions(+), 169 deletions(-) create mode 100644 packages/core/src/conversations/tools/approval-deciders.ts create mode 100644 packages/core/src/conversations/tools/network-access/tool.ts create mode 100644 packages/web/src/lib/tool-approvals.ts diff --git a/packages/contracts/src/threads.ts b/packages/contracts/src/threads.ts index 863a6cd9..467af4b6 100644 --- a/packages/contracts/src/threads.ts +++ b/packages/contracts/src/threads.ts @@ -182,6 +182,15 @@ export const toolApprovalStatusSchema = Schema.Literals([ export type ToolApprovalStatus = typeof toolApprovalStatusSchema.Type; +/** + * Who may decide a tool call's approval: `pod`, those who decide the pod's + * approvals; `sandbox-managers`, those who decide what the pod's sandbox may + * reach and has installed, for an agent's request to change that. + */ +export const toolApprovalDecidersSchema = Schema.Literals(["pod", "sandbox-managers"]); + +export type ToolApprovalDeciders = typeof toolApprovalDecidersSchema.Type; + /** * jsonValueSchema validates JSON recursively but exposes shallow types, so the * recursive message-part types built on it stay within TypeScript's @@ -219,11 +228,13 @@ export const toolCallPartSchema = Schema.Struct({ /** Null until the tool returns, and after it fails. */ output: Schema.NullOr(jsonValueSchema), status: toolCallStatusSchema, - /** Present only when this mutating connection call passed through approval policy. */ + /** Present only when the call passed through approval: a mutating connection call, or a request a person decides. */ approval: Schema.optional( Schema.NullOr( Schema.Struct({ status: toolApprovalStatusSchema, + /** Absent: `pod`. */ + deciders: Schema.optional(toolApprovalDecidersSchema), decidedByName: Schema.NullOr(Schema.String), decidedAt: Schema.NullOr(isoTimestampSchema), }), @@ -384,6 +395,8 @@ export const threadDetailsSchema = Schema.Struct({ capabilities: Schema.optional( Schema.Struct({ approveToolCalls: Schema.Boolean, + /** Whether this person may decide the pod's requests to change its sandbox. */ + approveSandboxRequests: Schema.optional(Schema.Boolean), }), ), routineExecution: Schema.NullOr(routineExecutionSchema), diff --git a/packages/core/src/authorization/access.ts b/packages/core/src/authorization/access.ts index 3b5f5259..59c4e5a9 100644 --- a/packages/core/src/authorization/access.ts +++ b/packages/core/src/authorization/access.ts @@ -1,4 +1,4 @@ -import type { WorkspaceRole } from "@sugabots/contracts"; +import type { ToolApprovalDeciders, WorkspaceRole } from "@sugabots/contracts"; import { and, asc, eq, type SQL, type SQLWrapper, sql } from "drizzle-orm"; import { Data, Effect } from "effect"; import type { Executor } from "../database/database.ts"; @@ -272,13 +272,17 @@ export const reachedPodStandingsFor = Effect.fn("Access.reachedPodStandingsFor") }); /** - * Whether `standing` may decide a tool call's approval: `approval.decide`, - * and `approval.routine.decide` as well for one raised while a routine runs. + * Whether `standing` may decide a tool call's approval that `deciders` decide. + * For `pod`: `approval.decide`, and `approval.routine.decide` as well for one + * raised while a routine runs. For `sandbox-managers`: deciding what the + * pod's sandbox may reach and has installed, wherever it was raised. */ export function mayDecideApprovals( standing: Pick, inRoutine: boolean, + deciders: ToolApprovalDeciders, ): boolean { + if (deciders === "sandbox-managers") return standing.may("sandbox.manage"); const needed: readonly PodPermission[] = inRoutine ? ["approval.decide", "approval.routine.decide"] : ["approval.decide"]; diff --git a/packages/core/src/conversations/chats/chat-view.ts b/packages/core/src/conversations/chats/chat-view.ts index 6486cd50..112bbead 100644 --- a/packages/core/src/conversations/chats/chat-view.ts +++ b/packages/core/src/conversations/chats/chat-view.ts @@ -14,6 +14,7 @@ import type { ChatMessagesPage, ChatPageQuery, PodChatMarkers, + ToolApprovalDeciders, } from "@sugabots/contracts"; import { DEFAULT_CHAT_PAGE_LIMIT, @@ -81,6 +82,7 @@ import { toParticipant, } from "../threads/participants.ts"; import { toToolCallPart } from "../threads/tool-calls.ts"; +import { decidersOf, SANDBOX_REQUEST_TOOLS } from "../tools/approval-deciders.ts"; import { toChat } from "./chat.ts"; /** @@ -777,22 +779,29 @@ const approvalDecidersIn = Effect.fn("ChatView.approvalDecidersIn")(function* ( standing, ]), ); - const mayDecide = (podId: string, inRoutine: boolean) => { + const mayDecide = (podId: string, inRoutine: boolean, deciders: ToolApprovalDeciders = "pod") => { const standing = standings.get(podId); - return standing !== undefined && mayDecideApprovals(standing, inRoutine); + return standing !== undefined && mayDecideApprovals(standing, inRoutine, deciders); }; const podIds = [...standings.keys()]; const decidablePods: DecidablePods = { outsideRoutines: podIds.filter((podId) => mayDecide(podId, false)), inRoutines: podIds.filter((podId) => mayDecide(podId, true)), + // Wherever they were raised: see `mayDecideApprovals`. + sandboxRequests: podIds.filter((podId) => mayDecide(podId, false, "sandbox-managers")), }; return { podIds, mayDecide, decidablePods }; }); -/** The pods where a person may decide approvals, those raised while a routine runs apart. */ +/** + * The pods where a person may decide approvals, those raised while a routine + * runs apart, and requests to change the pod's sandbox, which its sandbox + * managers decide (see `decidersOf`). + */ interface DecidablePods { outsideRoutines: readonly string[]; inRoutines: readonly string[]; + sandboxRequests: readonly string[]; } /** The tool calls waiting on a decision in `pods`, oldest first, each with the bot that asked and its chat. */ @@ -801,17 +810,21 @@ const waitingApprovalRows = Effect.fn("ChatView.waitingApprovalRows")(function* pods: DecidablePods, ) { const asking = alias(thread, "asking_thread"); + const sandboxRequest = inArray(toolCall.tool, [...SANDBOX_REQUEST_TOOLS]); const decidable: SQL[] = []; if (pods.outsideRoutines.length > 0) { decidable.push( - sql`${inArray(asking.podId, [...pods.outsideRoutines])} and ${isNull(routineExecutionIdOf(asking.id))}`, + sql`not ${sandboxRequest} and ${inArray(asking.podId, [...pods.outsideRoutines])} and ${isNull(routineExecutionIdOf(asking.id))}`, ); } if (pods.inRoutines.length > 0) { decidable.push( - sql`${inArray(asking.podId, [...pods.inRoutines])} and ${isNotNull(routineExecutionIdOf(asking.id))}`, + sql`not ${sandboxRequest} and ${inArray(asking.podId, [...pods.inRoutines])} and ${isNotNull(routineExecutionIdOf(asking.id))}`, ); } + if (pods.sandboxRequests.length > 0) { + decidable.push(sql`${sandboxRequest} and ${inArray(asking.podId, [...pods.sandboxRequests])}`); + } if (decidable.length === 0) return []; return yield* selectApprovals(db, asking) .where(and(eq(toolCall.approvalStatus, "pending"), or(...decidable))) @@ -917,6 +930,7 @@ const chatsAwaitingDecisionBy = Effect.fn("ChatView.chatsAwaitingDecisionBy")(fu chatId: asking.chatId, podId: asking.podId, routineExecutionId: routineExecutionIdOf(asking.id), + tool: toolCall.tool, }) .from(toolCall) .innerJoin(asking, eq(asking.id, toolCall.threadId)) @@ -924,8 +938,8 @@ const chatsAwaitingDecisionBy = Effect.fn("ChatView.chatsAwaitingDecisionBy")(fu if (pending.length === 0) return new Set(); const { mayDecide } = yield* approvalDecidersIn(db, workspaceId, userId); return new Set( - pending.flatMap(({ chatId, podId, routineExecutionId }) => - chatId && mayDecide(podId, routineExecutionId !== null) ? [chatId] : [], + pending.flatMap(({ chatId, podId, routineExecutionId, tool }) => + chatId && mayDecide(podId, routineExecutionId !== null, decidersOf(tool)) ? [chatId] : [], ), ); }); diff --git a/packages/core/src/conversations/chats/chats.test.ts b/packages/core/src/conversations/chats/chats.test.ts index 82193a92..d54b2f3b 100644 --- a/packages/core/src/conversations/chats/chats.test.ts +++ b/packages/core/src/conversations/chats/chats.test.ts @@ -1012,8 +1012,8 @@ describe.skipIf(!process.env.DATABASE_URL)("chats, against Postgres", async () = return written; }; - /** A reply in the chat that called a write and stopped for someone to approve it. */ - const askForApproval = async (threadId: string) => { + /** A reply in the chat that called `tool` and stopped for someone to approve it. */ + const askForApproval = async (threadId: string, tool = "linear__create_issue") => { const reply = await botWrites(threadId, "", 1); const [asked] = await onDatabase((db) => db @@ -1036,7 +1036,7 @@ describe.skipIf(!process.env.DATABASE_URL)("chats, against Postgres", async () = threadId, messageId: reply.id, turnId: asked.id, - tool: "linear__create_issue", + tool, approvalId: `approval-${crypto.randomUUID()}`, approvalStatus: "pending", status: "awaiting_approval", @@ -1202,6 +1202,16 @@ describe.skipIf(!process.env.DATABASE_URL)("chats, against Postgres", async () = ]); }); + it("leaves out a request to change the pod's sandbox, which only its managers decide", async () => { + const current = await chats.open({ workspace: workspaceId, podId, hostAgentId: agentId }); + await askForApproval(current.mainThreadId, "request_network_access"); + const callId = await askForApproval(current.mainThreadId); + + expect((await view.approvals(workspaceId)).waiting).toEqual([ + expect.objectContaining({ call: expect.objectContaining({ id: callId }) }), + ]); + }); + it("lists what the person has not read, and keeps what mentions them once read", async () => { const current = await chats.open({ workspace: workspaceId, podId, hostAgentId: agentId }); const news = await botWrites(current.mainThreadId, "The deploy finished.", 1); diff --git a/packages/core/src/conversations/routines/routines.test.ts b/packages/core/src/conversations/routines/routines.test.ts index 07e83d94..cba4a6ac 100644 --- a/packages/core/src/conversations/routines/routines.test.ts +++ b/packages/core/src/conversations/routines/routines.test.ts @@ -830,9 +830,12 @@ describe.skipIf(!process.env.DATABASE_URL)("Routines, against Postgres", async ( sdkToolCallId: "sdk-routine", tool: "linear__create_issue", input: { title: "From the routine" }, - connectionId: connected.id, - connectionRevision: 1, - remoteToolName: "create_issue", + binding: { + kind: "connection" as const, + connectionId: connected.id, + connectionRevision: 1, + remoteToolName: "create_issue", + }, mutating: true, atOffset: 0, }; @@ -846,7 +849,7 @@ describe.skipIf(!process.env.DATABASE_URL)("Routines, against Postgres", async ( tool: pending.tool, connectionId: connected.id, connectionRevision: 1, - remoteToolName: pending.remoteToolName, + remoteToolName: pending.binding.remoteToolName, }, ], modelInput: { model: "test", system: "test", messages: [] }, diff --git a/packages/core/src/conversations/thread-view.ts b/packages/core/src/conversations/thread-view.ts index 2f676159..c9c5e8d3 100644 --- a/packages/core/src/conversations/thread-view.ts +++ b/packages/core/src/conversations/thread-view.ts @@ -230,7 +230,12 @@ function toThreadDetails( // What this person may do with the approvals this thread raises, decided // once here so the conversation does not have to work it out from a role. capabilities: { - approveToolCalls: mayDecideApprovals(standing, row.routineExecutionId !== null), + approveToolCalls: mayDecideApprovals(standing, row.routineExecutionId !== null, "pod"), + approveSandboxRequests: mayDecideApprovals( + standing, + row.routineExecutionId !== null, + "sandbox-managers", + ), }, routineExecution: row.routineExecution ? toRoutineExecution(row.routineExecution) : null, participants: row.participants.map(({ user: person, agent: participant }) => diff --git a/packages/core/src/conversations/threads/tool-calls.ts b/packages/core/src/conversations/threads/tool-calls.ts index 743b3fc5..ea6c9ae5 100644 --- a/packages/core/src/conversations/threads/tool-calls.ts +++ b/packages/core/src/conversations/threads/tool-calls.ts @@ -3,6 +3,7 @@ import { eq, inArray } from "drizzle-orm"; import { Effect } from "effect"; import type { Executor } from "../../database/database.ts"; import { type ToolCallRow, toolCall, user } from "../../database/schema.ts"; +import { decidersOf } from "../tools/approval-deciders.ts"; /** * Reading tool calls back into the messages they belong to. @@ -50,6 +51,7 @@ export function toToolCallPart( approval: row.approvalStatus ? { status: row.approvalStatus, + deciders: decidersOf(row.tool), decidedByName, decidedAt: row.decidedAt?.toISOString() ?? null, } diff --git a/packages/core/src/conversations/tools/approval-deciders.ts b/packages/core/src/conversations/tools/approval-deciders.ts new file mode 100644 index 00000000..a443fc35 --- /dev/null +++ b/packages/core/src/conversations/tools/approval-deciders.ts @@ -0,0 +1,17 @@ +import type { ToolApprovalDeciders } from "@sugabots/contracts"; +import { REQUEST_NETWORK_ACCESS_TOOL } from "./network-access/tool.ts"; + +/** + * The tools whose calls ask to change the pod's sandbox, such as what it may + * reach, which those who manage the pod's sandbox decide. + */ +export const SANDBOX_REQUEST_TOOLS: readonly string[] = [REQUEST_NETWORK_ACCESS_TOOL]; + +/** + * Who decides a call to `tool` that waits for approval: those who manage the + * pod's sandbox for one of {@link SANDBOX_REQUEST_TOOLS}, and the pod's + * approvers for everything else. + */ +export function decidersOf(tool: string): ToolApprovalDeciders { + return SANDBOX_REQUEST_TOOLS.includes(tool) ? "sandbox-managers" : "pod"; +} diff --git a/packages/core/src/conversations/tools/network-access/tool.ts b/packages/core/src/conversations/tools/network-access/tool.ts new file mode 100644 index 00000000..6c9f655d --- /dev/null +++ b/packages/core/src/conversations/tools/network-access/tool.ts @@ -0,0 +1,68 @@ +import { sandboxHostSchema } from "@sugabots/contracts"; +import { tool } from "ai"; +import { Schema } from "effect"; +import type { RunEffect } from "../../../database/database.ts"; +import { blockOn } from "../../../sandboxes/allowed-hosts.ts"; +import { HostBlocked, type SandboxNetwork } from "../../../sandboxes/sandbox-network.ts"; +import type { Request } from "../sandbox.ts"; + +export const REQUEST_NETWORK_ACCESS_TOOL = "request_network_access"; + +/** + * The `request_network_access` tool: an agent asking for its pod's sandbox to + * reach another host. Every call waits for someone who decides what the pod's + * sandbox may reach to allow it (see `tools/approval-deciders.ts`), so the + * tool only runs once they have, and then adds the host to the pod. A host + * the workspace blocked, `blocked`, is refused without asking. + */ +export function requestNetworkAccess({ + turnId, + network, + blocked, + run, +}: { + turnId: string; + network: Pick; + blocked: readonly string[]; + run: RunEffect; +}): Request { + return { + tool: tool({ + description: + "Ask for the sandbox to be allowed to connect to a host it can't reach. An admin of this pod decides, and your reply waits until they have; once allowed, the pod's sandbox reaches the host. Ask only for a host the task needs, by its name, and use a wildcard such as *.example.com only when the task needs many of a domain's subdomains. A host the workspace has blocked is refused at once.", + inputSchema: Schema.Struct({ + host: sandboxHostSchema.annotate({ + description: + "The host's name in lowercase, such as api.example.com; *.example.com covers its subdomains but not example.com itself", + }), + }).pipe(Schema.toStandardSchemaV1, Schema.toStandardJSONSchemaV1), + execute: async ({ host }, { toolCallId }) => { + const grant = await run(network.grantRequest({ turnId, sdkToolCallId: toolCallId, host })); + switch (grant.kind) { + case "added": + return { status: "allowed", host, note: "The pod's sandbox can connect to it now." }; + case "blocked": + return { status: "failed", error: blockedMessage(host, grant.by) }; + case "not-allowed": + return { status: "failed", error: "The request wasn't allowed." }; + } + }, + }), + refusal: (input) => { + const host = hostOf(input); + const by = host === undefined ? undefined : blockOn(host, blocked); + return host === undefined || by === undefined ? undefined : blockedMessage(host, by); + }, + }; +} + +function blockedMessage(host: string, by: string) { + return new HostBlocked({ host, by }).userMessage; +} + +/** The host a call asks for; the SDK has checked its input against the schema by now. */ +function hostOf(input: unknown): string | undefined { + return typeof input === "object" && input !== null && "host" in input + ? String(input.host) + : undefined; +} diff --git a/packages/core/src/conversations/tools/sandbox.ts b/packages/core/src/conversations/tools/sandbox.ts index 24e96f45..7c3ef9dd 100644 --- a/packages/core/src/conversations/tools/sandbox.ts +++ b/packages/core/src/conversations/tools/sandbox.ts @@ -1,11 +1,14 @@ export * as SandboxTools from "./sandbox.ts"; -import type { ToolSet } from "ai"; +import type { Tool, ToolSet } from "ai"; import { Cause, Context, Effect, Exit, Layer, type Scope } from "effect"; -import { serviceOperations } from "../../database/database.ts"; -import { allowedHostsOf } from "../../sandboxes/allowed-hosts.ts"; +import { type RunEffect, serviceOperations } from "../../database/database.ts"; +import { allowedHostsOf, blockedHostsOf } from "../../sandboxes/allowed-hosts.ts"; import { PodSandboxes } from "../../sandboxes/pod-sandboxes.ts"; +import { SandboxNetwork } from "../../sandboxes/sandbox-network.ts"; import { SandboxProviderRepository } from "../../sandboxes/sandbox-provider-repository.ts"; +import type { UserMessage } from "../../user-message.ts"; +import { REQUEST_NETWORK_ACCESS_TOOL, requestNetworkAccess } from "./network-access/tool.ts"; import { allowedHostsNote, type OpenSandbox, @@ -22,10 +25,12 @@ import { /** * The tools that work in a pod's sandbox: `run_command`, `read_file` and - * `write_file`. Offered to every turn, and usable while the agent uses the - * sandbox and the workspace has an enabled sandbox provider, looked up on - * every call, so enabling one applies from the next turn. The sandbox is - * opened by a turn's first call to one of the tools, not when the turn starts. + * `write_file`, and `request_network_access`, whose calls wait for a person, + * to reach a host the sandbox may not. Offered to every turn, and usable + * while the agent uses the sandbox and the workspace has an enabled sandbox + * provider, looked up on every call, so enabling one applies from the next + * turn. The sandbox is opened by a turn's first call to one of the tools, not + * when the turn starts. */ export interface Interface { /** @@ -38,27 +43,58 @@ export interface Interface { } export interface Offered { + /** The tools that run when called. */ readonly tools: ToolSet; - /** Calls to the tools are refused when this is false. */ + /** The tools whose calls wait for a person to allow them first: who is in `tools/approval-deciders.ts`. */ + readonly requests: Readonly>; + /** Calls to the tools and the requests are refused when this is false. */ readonly usable: boolean; /** What the model is told of the sandbox with the turn, as it may change between turns. */ readonly note: string | undefined; } +/** + * A tool whose calls wait for a person to allow them, unless `refusal` names + * a reason nobody could: such a call is refused without asking anyone. + */ +export interface Request { + readonly tool: Tool; + readonly refusal: (input: unknown) => UserMessage | undefined; +} + export class Service extends Context.Service()("@sugabots/core/SandboxTools") {} export const make = Effect.gen(function* () { const providers = yield* SandboxProviderRepository.Service; const podSandboxes = yield* PodSandboxes.Service; + const network = yield* SandboxNetwork.Service; const operation = yield* serviceOperations("SandboxTools"); + const offeredIn = ({ turnId, place, openSandbox, run, blocked }: Behind) => ({ + tools: { + [RUN_COMMAND_TOOL]: runCommandTool(openSandbox, place), + [READ_FILE_TOOL]: readFileTool(openSandbox, place), + [WRITE_FILE_TOOL]: writeFileTool(openSandbox, place), + }, + requests: { + [REQUEST_NETWORK_ACCESS_TOOL]: requestNetworkAccess({ turnId, network, blocked, run }), + }, + }); return Service.of({ forTurn: ({ pod, turnId, threadId, agentId, usesSandbox }) => Effect.gen(function* (): Effect.fn.Return { const place = placeOf({ threadId, agentId }); const provider = usesSandbox ? yield* providers.enabled(pod.workspaceId) : undefined; - if (!provider) - return { tools: toolsIn(NEVER_OPENED, place), usable: false, note: NO_SANDBOX_NOTE }; - const allowedHosts = yield* operation("forTurn", allowedHostsOf(pod)); + if (!provider) { + return { + ...offeredIn({ turnId, place, openSandbox: NEVER_OPENED, run: NEVER_RUN, blocked: [] }), + usable: false, + note: NO_SANDBOX_NOTE, + }; + } + const [allowedHosts, blocked] = yield* operation( + "forTurn", + Effect.all([allowedHostsOf(pod), blockedHostsOf(pod.workspaceId)]), + ); const holder: PodSandboxes.Holder = { kind: "turn", id: turnId }; yield* Effect.addFinalizer(() => podSandboxes.release(holder)); yield* Effect.forkScoped( @@ -81,8 +117,19 @@ export const make = Effect.gen(function* () { throw Cause.squash(exit.cause); }), ); + const run = (effect: Effect.Effect) => + runPromiseExit(effect).then((exit) => { + if (Exit.isSuccess(exit)) return exit.value; + throw Cause.squash(exit.cause); + }); return { - tools: toolsIn(openSandbox, place), + ...offeredIn({ + turnId, + place, + openSandbox, + run, + blocked: blocked.map((row) => row.host), + }), usable: true, note: allowedHostsNote(allowedHosts), }; @@ -93,20 +140,24 @@ export const make = Effect.gen(function* () { export const layerNoDeps = Layer.effect(Service, make); export const layer = layerNoDeps.pipe( - Layer.provide([PodSandboxes.layer, SandboxProviderRepository.layer]), + Layer.provide([PodSandboxes.layer, SandboxNetwork.layer, SandboxProviderRepository.layer]), ); -function toolsIn(openSandbox: OpenSandbox, place: Place): ToolSet { - return { - [RUN_COMMAND_TOOL]: runCommandTool(openSandbox, place), - [READ_FILE_TOOL]: readFileTool(openSandbox, place), - [WRITE_FILE_TOOL]: writeFileTool(openSandbox, place), - }; +/** What a turn's tools work through. */ +interface Behind { + readonly turnId: string; + readonly place: Place; + readonly openSandbox: OpenSandbox; + readonly run: RunEffect; + /** Hosts a request is refused for without asking anyone. */ + readonly blocked: readonly string[]; } -/** Behind tools that aren't usable, whose calls are refused before they run. */ +// Behind tools that aren't usable, whose calls are refused before they run. const NEVER_OPENED: OpenSandbox = () => Promise.reject(new Error("The sandbox tools aren't usable")); +const NEVER_RUN: RunEffect = () => + Promise.reject(new Error("The sandbox tools aren't usable")); const NO_SANDBOX_NOTE = "You have no sandbox in this turn, so calls to the sandbox tools are refused. If the task needs one, say so: a workspace admin can enable a sandbox provider and let you use it."; @@ -124,5 +175,5 @@ export interface Turn { /** No sandbox tools, for cases that offer none. */ export const none: Interface = { - forTurn: () => Effect.succeed({ tools: {}, usable: false, note: undefined }), + forTurn: () => Effect.succeed({ tools: {}, requests: {}, usable: false, note: undefined }), }; diff --git a/packages/core/src/conversations/tools/sandbox/tools.ts b/packages/core/src/conversations/tools/sandbox/tools.ts index 40151c75..f19d8c30 100644 --- a/packages/core/src/conversations/tools/sandbox/tools.ts +++ b/packages/core/src/conversations/tools/sandbox/tools.ts @@ -4,6 +4,7 @@ import { Effect, Schema } from "effect"; import type { PodSandboxes } from "../../../sandboxes/pod-sandboxes.ts"; import { Sandboxes } from "../../../sandboxes/sandboxes.ts"; import { UserMessage } from "../../../user-message.ts"; +import { REQUEST_NETWORK_ACCESS_TOOL } from "../network-access/tool.ts"; export const RUN_COMMAND_TOOL = "run_command"; export const READ_FILE_TOOL = "read_file"; @@ -134,7 +135,7 @@ const pathIn = (place: Place) => export function runCommandTool(openSandbox: OpenSandbox, place: Place) { return tool({ - description: `Run a bash command in the pod's sandbox, a Linux machine shared by the agents in this pod. Commands start in this thread's folder, ${place.folder}: a scratchpad kept between the thread's turns and shared with the other agents in the thread, so clone and build here. Your home, ${place.home} (also $HOME), is your own and kept across every thread in the pod: keep notes, settings and tools you want everywhere there. Nothing else carries over between commands, so cd or export in the same command. Returns the exit code and the end of stdout and stderr. A command still running at its timeout is stopped. It can't ask a person anything, so pass flags that skip prompts. It reaches only the hosts the turn's note lists.`, + description: `Run a bash command in the pod's sandbox, a Linux machine shared by the agents in this pod. Commands start in this thread's folder, ${place.folder}: a scratchpad kept between the thread's turns and shared with the other agents in the thread, so clone and build here. Your home, ${place.home} (also $HOME), is your own and kept across every thread in the pod: keep notes, settings and tools you want everywhere there. Nothing else carries over between commands, so cd or export in the same command. Returns the exit code and the end of stdout and stderr. A command still running at its timeout is stopped. It can't ask a person anything, so pass flags that skip prompts. It reaches only the hosts the turn's note lists; to reach another the task needs, call ${REQUEST_NETWORK_ACCESS_TOOL}.`, inputSchema: Schema.Struct({ command: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20_000)).annotate({ description: "The bash command to run", @@ -218,5 +219,5 @@ export function writeFileTool(openSandbox: OpenSandbox, place: Place) { * the provider's cached prompt. */ export function allowedHostsNote(allowedHosts: readonly string[]): string { - return `The pod's sandbox connects only to these hosts (*. covers a domain's subdomains): ${allowedHosts.join(", ")}. A connection to any other host fails as if it weren't there: a name that doesn't resolve, a TLS error or an empty reply. When the task needs another, say which and why, so a workspace admin can allow it.`; + return `The pod's sandbox connects only to these hosts (*. covers a domain's subdomains): ${allowedHosts.join(", ")}. A connection to any other host fails as if it weren't there: a name that doesn't resolve, a TLS error or an empty reply. To reach one the task needs, call ${REQUEST_NETWORK_ACCESS_TOOL}.`; } diff --git a/packages/core/src/conversations/turns/approvals/approved-calls.ts b/packages/core/src/conversations/turns/approvals/approved-calls.ts index b0c0dfe9..46001def 100644 --- a/packages/core/src/conversations/turns/approvals/approved-calls.ts +++ b/packages/core/src/conversations/turns/approvals/approved-calls.ts @@ -38,9 +38,7 @@ export interface Interface { tool: string; input: unknown; atOffset: number; - connectionId: string; - connectionRevision: number; - remoteToolName: string; + binding: ToolCallRepository.ApprovalBinding; }) => Effect.Effect; } diff --git a/packages/core/src/conversations/turns/controls.ts b/packages/core/src/conversations/turns/controls.ts index aa759579..01bb171f 100644 --- a/packages/core/src/conversations/turns/controls.ts +++ b/packages/core/src/conversations/turns/controls.ts @@ -7,6 +7,7 @@ import { afterCommit, query, serviceOperations, transaction } from "../../databa import { thread, toolCall, turn } from "../../database/schema.ts"; import { isUuid } from "../../ids/ids.ts"; import { type UserFacing, UserMessage } from "../../user-message.ts"; +import { decidersOf } from "../tools/approval-deciders.ts"; import { awaitsDecisions } from "./lifecycle.ts"; import { TurnRepository } from "./repository.ts"; import { TurnSignals } from "./signals.ts"; @@ -62,7 +63,8 @@ export const makeControls = Effect.gen(function* () { Effect.gen(function* () { // Inside the transaction that sends or records the decision, so a // demotion a moment earlier is seen. - const decider = yield* authorization.pod(input.podId, "approval.decide"); + // Reaching the pod is enough to ask: who decides depends on the call. + const decider = yield* authorization.pod(input.podId, "pod.read"); const { pod } = decider; if (!isUuid(input.toolCallId)) return yield* new ToolApprovalNotFound(); const [candidate] = yield* query((db) => @@ -92,7 +94,7 @@ export const makeControls = Effect.gen(function* () { } if (!awaitsDecision(candidate.call)) return yield* new ToolApprovalConflict(); const forRoutine = yield* admission.forRoutine(candidate.threadId); - if (!mayDecideApprovals(decider, forRoutine)) { + if (!mayDecideApprovals(decider, forRoutine, decidersOf(candidate.call.tool))) { return yield* new ToolApprovalForbidden(); } const approvalId = candidate.call.approvalId; diff --git a/packages/core/src/conversations/turns/repository.ts b/packages/core/src/conversations/turns/repository.ts index ad2fcdb9..032e6a9e 100644 --- a/packages/core/src/conversations/turns/repository.ts +++ b/packages/core/src/conversations/turns/repository.ts @@ -782,18 +782,30 @@ const SdkModelMessage = Schema.declare( const OptionalCount = Schema.optional(Schema.Int); +/** + * A call the suspended turn waits on, and what it must still match to run + * (see `ToolCallRepository.ApprovalBinding`). A connection's call is stored + * flat, untagged, as checkpoints were before built-in tools asked for approval. + */ +const CheckpointApproval = Schema.Union([ + Schema.Struct({ + approvalId: Schema.String, + tool: Schema.String, + connectionId: Schema.String, + connectionRevision: Schema.Int, + remoteToolName: Schema.String, + }), + Schema.Struct({ + approvalId: Schema.String, + tool: Schema.String, + builtIn: Schema.Literal(true), + }), +]); + /** Everything a suspended turn needs to continue once its approvals are decided. */ export const TurnCheckpoint = Schema.Struct({ messages: Schema.Array(SdkModelMessage), - approvals: Schema.Array( - Schema.Struct({ - approvalId: Schema.String, - tool: Schema.String, - connectionId: Schema.String, - connectionRevision: Schema.Int, - remoteToolName: Schema.String, - }), - ), + approvals: Schema.Array(CheckpointApproval), modelInput: Schema.Struct({ model: Schema.String, system: Schema.String, diff --git a/packages/core/src/conversations/turns/tool-calls/recorded.test.ts b/packages/core/src/conversations/turns/tool-calls/recorded.test.ts index cbece943..87b21d29 100644 --- a/packages/core/src/conversations/turns/tool-calls/recorded.test.ts +++ b/packages/core/src/conversations/turns/tool-calls/recorded.test.ts @@ -180,9 +180,12 @@ describe("a recorded tool", () => { }), ), }, - connectionId: "0199a3a0-0000-7000-8000-000000000021", - connectionRevision: 1, - remoteToolName: "probe", + binding: { + kind: "connection", + connectionId: "0199a3a0-0000-7000-8000-000000000021", + connectionRevision: 1, + remoteToolName: "probe", + }, }, }, ); diff --git a/packages/core/src/conversations/turns/tool-calls/recorded.ts b/packages/core/src/conversations/turns/tool-calls/recorded.ts index 11add086..cc986e01 100644 --- a/packages/core/src/conversations/turns/tool-calls/recorded.ts +++ b/packages/core/src/conversations/turns/tool-calls/recorded.ts @@ -27,9 +27,7 @@ export interface RecordingOptions { mutating?: boolean; approval?: { approvals: Pick; - connectionId: string; - connectionRevision: number; - remoteToolName: string; + binding: ToolCallRepository.ApprovalBinding; }; } @@ -85,9 +83,7 @@ export function recorded(key: string, tool: Tool, options: RecordingOptions): To tool: key, input, atOffset, - connectionId: approval.connectionId, - connectionRevision: approval.connectionRevision, - remoteToolName: approval.remoteToolName, + binding: approval.binding, }) .pipe( Effect.catchTag("ToolExecutionRefused", (refused) => diff --git a/packages/core/src/conversations/turns/tool-calls/repository.test.ts b/packages/core/src/conversations/turns/tool-calls/repository.test.ts index 08566436..49c402be 100644 --- a/packages/core/src/conversations/turns/tool-calls/repository.test.ts +++ b/packages/core/src/conversations/turns/tool-calls/repository.test.ts @@ -2,7 +2,7 @@ import { eq } from "drizzle-orm"; import { Context, Effect, Layer, ManagedRuntime } from "effect"; import { WorkflowEngine } from "effect/unstable/workflow"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { ActionForbidden, ResourceHidden } from "../../../authorization/access.ts"; +import { ResourceHidden } from "../../../authorization/access.ts"; import type { CommittedEvent } from "../../../database/events/outbox.ts"; import { connection, @@ -26,7 +26,7 @@ import { Chats } from "../../chats/chats.ts"; import { conversationsForTests } from "../../testing.ts"; import { ThreadView } from "../../thread-view.ts"; import { ApprovedToolCalls } from "../approvals/approved-calls.ts"; -import { ToolApprovalConflict } from "../controls.ts"; +import { ToolApprovalConflict, ToolApprovalForbidden } from "../controls.ts"; import { type PreparedTurn, replyTurnOf, TurnExecution } from "../execution.ts"; import { type TurnCheckpoint, TurnRepository } from "../repository.ts"; import { TurnSignals } from "../signals.ts"; @@ -40,6 +40,7 @@ import { } from "../turn.workflow.ts"; import { Turns } from "../turns.ts"; import { + type ApprovalBinding, boundedJson, MAX_STORED_JSON_CHARACTERS, type PendingToolApproval, @@ -126,27 +127,34 @@ describe.skipIf(!process.env.DATABASE_URL)("tool calls, against Postgres", async sdkToolCallId: "sdk-create-1", tool: "linear__create_issue", input: { title: "Fix mobile navigation" }, - connectionId, - connectionRevision: 1, - remoteToolName: "create_issue", + binding: connectionBinding("create_issue"), mutating: true, atOffset: 0, ...overrides, }); + const connectionBinding = (remoteToolName: string): ApprovalBinding => ({ + kind: "connection", + connectionId, + connectionRevision: 1, + remoteToolName, + }); + /** Parks `pending` as the turn's one approval. */ const park = (pending: PendingToolApproval) => turns.suspend( replyTurnOf(prepared), checkpoint({ approvals: [ - { - approvalId: pending.approvalId, - tool: pending.tool, - connectionId, - connectionRevision: 1, - remoteToolName: pending.remoteToolName, - }, + pending.binding.kind === "connection" + ? { + approvalId: pending.approvalId, + tool: pending.tool, + connectionId: pending.binding.connectionId, + connectionRevision: pending.binding.connectionRevision, + remoteToolName: pending.binding.remoteToolName, + } + : { approvalId: pending.approvalId, tool: pending.tool, builtIn: true as const }, ], reply: { content: "", @@ -170,9 +178,7 @@ describe.skipIf(!process.env.DATABASE_URL)("tool calls, against Postgres", async tool: pending.tool, input: pending.input, atOffset: pending.atOffset, - connectionId, - connectionRevision: 1, - remoteToolName: pending.remoteToolName, + binding: pending.binding, }; } @@ -266,7 +272,7 @@ describe.skipIf(!process.env.DATABASE_URL)("tool calls, against Postgres", async sdkToolCallId: "sdk-read", tool: "linear__list_issues", input: {}, - remoteToolName: "list_issues", + binding: connectionBinding("list_issues"), mutating: false, }), ); @@ -283,6 +289,27 @@ describe.skipIf(!process.env.DATABASE_URL)("tool calls, against Postgres", async expect(resumed?.mutationStarted).toBe(false); }); + it("starts an allowed call to a built-in tool, which no connection's settings bind", async () => { + const execution = await allowAndResume( + pendingCall({ + sdkToolCallId: "sdk-built-in", + tool: "request_network_access", + input: { host: "api.example.com" }, + binding: { kind: "built-in" }, + }), + ); + await onDatabase((db) => + db + .update(connection) + .set({ toolAccess: { create_issue: "off" } }) + .where(eq(connection.id, connectionId)), + ); + + const running = await approvals.beginExecution(execution); + + expect(running.status).toBe("running"); + }); + it("refuses an allowed call after the reviewed connection configuration changes", async () => { const execution = await allowAndResume(pendingCall({ sdkToolCallId: "sdk-revision" })); await onDatabase((db) => @@ -345,7 +372,7 @@ describe.skipIf(!process.env.DATABASE_URL)("tool calls, against Postgres", async await expect( approvalsAs(viewer.id).decide({ podId, toolCallId: pending.id, decision: "allow_once" }), - ).rejects.toBeInstanceOf(ActionForbidden); + ).rejects.toBeInstanceOf(ToolApprovalForbidden); const [undecided] = await onDatabase((db) => db.select().from(toolCall).where(eq(toolCall.id, pending.id)), ); diff --git a/packages/core/src/conversations/turns/tool-calls/repository.ts b/packages/core/src/conversations/turns/tool-calls/repository.ts index a3c91ab3..c7e69054 100644 --- a/packages/core/src/conversations/turns/tool-calls/repository.ts +++ b/packages/core/src/conversations/turns/tool-calls/repository.ts @@ -94,9 +94,7 @@ export interface Interface { sdkToolCallId: string; tool: string; input: unknown; - connectionId: string; - connectionRevision: number; - remoteToolName: string; + binding: ApprovalBinding; }) => Effect.Effect; /** * Fails these turns' unfinished calls, denying pending approvals, for turns @@ -215,9 +213,7 @@ export const make = Effect.gen(function* () { approvalId: approval.approvalId, approvalStatus: "pending" as const, approvalReason: approval.reason ?? null, - connectionId: approval.connectionId, - connectionRevision: approval.connectionRevision, - remoteToolName: approval.remoteToolName, + ...bindingColumns(approval.binding), input: boundedJson(approval.input), executionInput: executionJson(approval.input), status: "awaiting_approval" as const, @@ -299,28 +295,31 @@ export const make = Effect.gen(function* () { .for("update", { of: turn }), ); if (!scope || !mayRunTools(scope)) return refusedExecution("The turn is not running"); - const [approvedConnection] = yield* query((db) => - db - .select({ toolAccess: connection.toolAccess }) - .from(connection) - .where( - and( - eq(connection.id, input.connectionId), - eq(connection.workspaceId, scope.workspaceId), - eq(connection.podId, scope.podId), - eq(connection.configurationRevision, input.connectionRevision), - ), - ) - .limit(1) - .for("update"), - ); - if (!approvedConnection) { - return refusedExecution("The connection's configuration changed after approval"); - } - // Choosing what bots may do with a tool leaves the revision alone, so - // a tool somebody turned off after the approval is caught here. - if (approvedConnection.toolAccess[input.remoteToolName] === "off") { - return refusedExecution("The tool was turned off after approval"); + const { binding } = input; + if (binding.kind === "connection") { + const [approvedConnection] = yield* query((db) => + db + .select({ toolAccess: connection.toolAccess }) + .from(connection) + .where( + and( + eq(connection.id, binding.connectionId), + eq(connection.workspaceId, scope.workspaceId), + eq(connection.podId, scope.podId), + eq(connection.configurationRevision, binding.connectionRevision), + ), + ) + .limit(1) + .for("update"), + ); + if (!approvedConnection) { + return refusedExecution("The connection's configuration changed after approval"); + } + // Choosing what bots may do with a tool leaves the revision alone, so + // a tool somebody turned off after the approval is caught here. + if (approvedConnection.toolAccess[binding.remoteToolName] === "off") { + return refusedExecution("The tool was turned off after approval"); + } } const row = yield* lockedCall( and( @@ -328,6 +327,7 @@ export const make = Effect.gen(function* () { eq(toolCall.sdkToolCallId, input.sdkToolCallId), ), ); + // Every call bound to an approval was parked for a person to allow first. if (!row) return refusedExecution("The call has no approval record"); if (!sameCallAsApproved(row, input)) { return refusedExecution( @@ -390,7 +390,21 @@ export const MAX_STORED_JSON_CHARACTERS = 64_000; export type ToolCallOutcome = { output: unknown } | { error: UserMessage }; -/** A connection tool call a reply asked for, to be parked until a person decides it. */ +/** + * What an approved call must still match when it runs, beyond its tool and + * input: for a connection's tool, the connection configured as it was when + * the person allowed it. A built-in tool is the same tool wherever it runs. + */ +export type ApprovalBinding = + | { + readonly kind: "connection"; + readonly connectionId: string; + readonly connectionRevision: number; + readonly remoteToolName: string; + } + | { readonly kind: "built-in" }; + +/** A tool call a reply asked for, to be parked until a person decides it. */ export interface PendingToolApproval { id: string; approvalId: string; @@ -398,9 +412,7 @@ export interface PendingToolApproval { tool: string; input: unknown; reason?: string; - connectionId: string; - connectionRevision: number; - remoteToolName: string; + binding: ApprovalBinding; /** Whether the tool may change something, as opposed to one the connection's `ask` holds back. */ mutating: boolean; atOffset: number; @@ -463,13 +475,29 @@ function sameCallAsApproved( row.threadId === input.threadId && row.messageId === input.messageId && row.tool === input.tool && - row.connectionId === input.connectionId && - row.connectionRevision === input.connectionRevision && - row.remoteToolName === input.remoteToolName && + isDeepStrictEqual( + { + connectionId: row.connectionId, + connectionRevision: row.connectionRevision, + remoteToolName: row.remoteToolName, + }, + bindingColumns(input.binding), + ) && isDeepStrictEqual(row.executionInput, executionJson(input.input)) ); } +/** How a binding is stored on the call: a built-in tool's leaves the connection's columns empty. */ +function bindingColumns(binding: ApprovalBinding) { + return binding.kind === "connection" + ? { + connectionId: binding.connectionId, + connectionRevision: binding.connectionRevision, + remoteToolName: binding.remoteToolName, + } + : { connectionId: null, connectionRevision: null, remoteToolName: null }; +} + /** The call as an event carries it once it has changed. */ function toolCallChange(row: ToolCallRow, decidedByName: string | null = null): ToolCallChange { return { diff --git a/packages/core/src/conversations/turns/tools.ts b/packages/core/src/conversations/turns/tools.ts index ad63457d..57c1856f 100644 --- a/packages/core/src/conversations/turns/tools.ts +++ b/packages/core/src/conversations/turns/tools.ts @@ -108,6 +108,28 @@ export function toolsForTurn(prepared: PreparedTurn, deps: ToolDependencies): To ? recorded(key, tool, { ...recording, mutating: key !== READ_FILE_TOOL }) : refused(key, tool, NO_SANDBOX, recording); } + // What a request does once allowed changes something, so a turn that fails + // afterwards is not run again. + for (const [key, request] of Object.entries(deps.sandbox.requests)) { + if (!deps.sandbox.usable) { + tools[key] = refused(key, request.tool, NO_SANDBOX, recording); + continue; + } + const asked = recorded(key, request.tool, { + ...recording, + mutating: true, + approval: { approvals: deps.approvals, binding: { kind: "built-in" } }, + }); + // A refused call was never put to anyone, so it has no approval to run under. + tools[key] = { + ...asked, + execute: (input, options) => { + const reason = request.refusal(input); + const call = reason ? refused(key, request.tool, reason, recording) : asked; + return call.execute?.(input, options); + }, + }; + } const connectionTools: Record = {}; for (const [key, offered] of Object.entries(deps.connections)) { const approvalBound = deps.approvalBoundTools?.has(key) ?? false; @@ -122,12 +144,7 @@ export function toolsForTurn(prepared: PreparedTurn, deps: ToolDependencies): To mutating: offered.mutating || approvalBound, ...(offered.access === "ask" || approvalBound ? { - approval: { - approvals: deps.approvals, - connectionId: offered.connectionId, - connectionRevision: offered.connectionRevision, - remoteToolName: offered.remoteToolName, - }, + approval: { approvals: deps.approvals, binding: bindingOf(offered) }, } : {}), }); @@ -173,3 +190,13 @@ export function toolsForTurn(prepared: PreparedTurn, deps: ToolDependencies): To }); return tools; } + +/** What a call to a connection's tool is approved against: the connection as it is configured now. */ +export function bindingOf(offered: OfferedTool): ToolCallRepository.ApprovalBinding { + return { + kind: "connection", + connectionId: offered.connectionId, + connectionRevision: offered.connectionRevision, + remoteToolName: offered.remoteToolName, + }; +} diff --git a/packages/core/src/conversations/turns/turn.steps.test.ts b/packages/core/src/conversations/turns/turn.steps.test.ts index 9f3633b6..623d3b6f 100644 --- a/packages/core/src/conversations/turns/turn.steps.test.ts +++ b/packages/core/src/conversations/turns/turn.steps.test.ts @@ -15,6 +15,7 @@ import { SEARCH_HISTORY_TOOL } from "../threads/message-text.ts"; import { BuiltInTools } from "../tools/built-in.ts"; import { Collaborations } from "../tools/collaborate/collaborations.ts"; import { ConnectionTools } from "../tools/connections.ts"; +import { REQUEST_NETWORK_ACCESS_TOOL, requestNetworkAccess } from "../tools/network-access/tool.ts"; import { SandboxTools } from "../tools/sandbox.ts"; import { CALL_TOOL, TOOL_SEARCH } from "../tools/tool-search/tool.ts"; import { @@ -348,6 +349,65 @@ describe("runSegment", () => { expect(turnNote).not.toContain("drive__lookup"); }); + it("refuses a request for a host the workspace blocked at once, asking nobody", async () => { + const { turns } = fakes(); + const usingSandbox: PreparedTurn = { + ...prepared, + context: { + ...prepared.context, + agent: { ...prepared.context.agent, usesSandbox: true }, + }, + }; + const execution: Given["execution"] = { prepare: () => Effect.succeed(usingSandbox) }; + const calls = toolCalls(); + let received: Models.StreamRequest | undefined; + const model = Models.fromStream((input) => { + received = input; + return Effect.succeed(streamed(chunks("Done"))); + }); + const request = requestNetworkAccess({ + turnId: prepared.turnId, + network: { grantRequest: () => Effect.die("a refused request is never granted") }, + blocked: ["*.pastebin.com"], + run: () => Promise.reject(new Error("a refused request runs nothing")), + }); + + await runWithServices( + segmentWith({ + execution, + turns, + model, + events: eventBus(), + collaborations: collaborations(), + toolCalls: calls, + sandboxTools: { + forTurn: () => + Effect.succeed({ + tools: {}, + requests: { [REQUEST_NETWORK_ACCESS_TOOL]: request }, + usable: true, + note: undefined, + }), + }, + }), + ); + const approval = (received?.toolApproval ?? {}) as Record unknown>; + const blocked = { host: "paste.pastebin.com" }; + const output = await received?.tools?.[REQUEST_NETWORK_ACCESS_TOOL]?.execute?.(blocked, { + toolCallId: "sdk-1", + messages: [], + } as never); + + expect(approval[REQUEST_NETWORK_ACCESS_TOOL]?.(blocked)).toBe("not-applicable"); + expect(approval[REQUEST_NETWORK_ACCESS_TOOL]?.({ host: "api.example.com" })).toBe( + "user-approval", + ); + expect(output).toMatchObject({ status: "failed", error: expect.stringContaining("blocked") }); + expect(calls.close).toHaveBeenCalledWith(expect.anything(), { + error: expect.stringContaining("*.pastebin.com"), + }); + }); + it("still offers a built-in tool the agent has switched off, refusing each call to it", async () => { const { execution, turns } = fakes(); const probe = tool({ @@ -556,6 +616,7 @@ interface Given { events: EventBus.Interface; builtInTools?: BuiltInTools.Interface; connectionTools?: ConnectionTools.Interface; + sandboxTools?: SandboxTools.Interface; } /** @@ -569,7 +630,7 @@ function segmentWith(given: Given) { Layer.succeed(Models.Service, given.model), Layer.succeed(EventBus.Service, given.events), Layer.succeed(BuiltInTools.Service, given.builtInTools ?? BuiltInTools.none), - Layer.succeed(SandboxTools.Service, SandboxTools.none), + Layer.succeed(SandboxTools.Service, given.sandboxTools ?? SandboxTools.none), Layer.succeed(ConnectionTools.Service, given.connectionTools ?? ConnectionTools.none), unimplemented(TurnExecution.Service, given.execution), unimplemented(TurnRepository.Service, given.turns), diff --git a/packages/core/src/conversations/turns/turn.steps.ts b/packages/core/src/conversations/turns/turn.steps.ts index 4bd2698b..e6df5180 100644 --- a/packages/core/src/conversations/turns/turn.steps.ts +++ b/packages/core/src/conversations/turns/turn.steps.ts @@ -1,4 +1,5 @@ import { streamEvent, threadChannel } from "@sugabots/contracts"; +import type { ToolApprovalConfiguration, ToolSet } from "ai"; import { Cause, Clock, @@ -47,7 +48,7 @@ import { TurnRepository, } from "./repository.ts"; import { ToolCallRepository } from "./tool-calls/repository.ts"; -import { toolsForTurn } from "./tools.ts"; +import { bindingOf, toolsForTurn } from "./tools.ts"; import { type SegmentOutcome, TurnSteps } from "./turn.workflow.ts"; /** Token deltas are batched so a fast model does not publish per token. */ @@ -368,11 +369,11 @@ const streamReply = ( const events = yield* EventBus.Service; const builtInTools = yield* BuiltInTools.Service; const connectionTools = yield* ConnectionTools.Service; + const sandboxTools = yield* SandboxTools.Service; const turns = yield* TurnRepository.Service; const toolCalls = yield* ToolCallRepository.Service; const collaborations = yield* Collaborations.Service; const approvals = yield* ApprovedToolCalls.Service; - const sandboxTools = yield* SandboxTools.Service; const agents = yield* AgentRepository.Service; const signal = yield* Effect.abortSignal; @@ -413,6 +414,12 @@ const streamReply = ( ); const approvalBoundTools = new Set(); for (const binding of prepared.checkpoint?.approvals ?? []) { + if ("builtIn" in binding) { + if (!sandbox.usable || !sandbox.requests[binding.tool]) { + return yield* new ApprovedToolChanged({ tool: binding.tool }); + } + continue; + } const offered = connections.tools[binding.tool]; if ( !offered || @@ -424,6 +431,19 @@ const streamReply = ( } approvalBoundTools.add(binding.tool); } + const toolApproval: ToolApprovalConfiguration = { + ...callToolApproval(connections.tools), + // A request refused outright runs at once, to be recorded as refused. + ...Object.fromEntries( + Object.entries(sandbox.requests).map(([key, request]) => [ + key, + (input: unknown) => + sandbox.usable && request.refusal(input) === undefined + ? "user-approval" + : "not-applicable", + ]), + ), + }; const tools = toolsForTurn(prepared, { collaborations, calls: toolCalls, @@ -495,7 +515,7 @@ const streamReply = ( messages: modelInput.messages, continuationMessages: segmentMessages, tools, - toolApproval: callToolApproval(connections.tools), + toolApproval, maxSteps: Math.max(1, TURN_MODEL_CALLS - (prepared.checkpoint?.modelCalls ?? 0)), }); @@ -517,23 +537,40 @@ const streamReply = ( const ids = yield* Ids.Service; const pending = yield* Effect.forEach(finished.approvalRequests, (request) => { const target = findApprovalTarget(connections.tools, request.toolCall); - if (!target) { + const asked: + | Pick< + ToolCallRepository.PendingToolApproval, + "tool" | "input" | "binding" | "mutating" + > + | undefined = sandbox.requests[request.toolCall.toolName] + ? { + tool: request.toolCall.toolName, + input: request.toolCall.input, + binding: { kind: "built-in" }, + mutating: true, + } + : target + ? { + tool: target.key, + input: target.input, + binding: bindingOf(target.offered), + mutating: target.offered.mutating, + } + : undefined; + if (!asked) { return Effect.fail(new ApprovalForUnknownTool({ tool: request.toolCall.toolName })); } - const { offered } = target; - return Effect.map(ids.next, (id) => ({ - id, - approvalId: request.approvalId, - sdkToolCallId: request.toolCall.toolCallId, - tool: target.key, - input: target.input, - reason: request.reason, - connectionId: offered.connectionId, - connectionRevision: offered.connectionRevision, - remoteToolName: offered.remoteToolName, - mutating: offered.mutating, - atOffset, - })); + return Effect.map( + ids.next, + (id): ToolCallRepository.PendingToolApproval => ({ + id, + approvalId: request.approvalId, + sdkToolCallId: request.toolCall.toolCallId, + reason: request.reason, + ...asked, + atOffset, + }), + ); }); // Only the checkpoint's reply places the calls: they exist once the // turn suspends, and a turn that may not suspend ends with the reply @@ -551,13 +588,17 @@ const streamReply = ( approvals: pending, checkpoint: { messages: [...segmentMessages, ...finished.responseMessages], - approvals: pending.map((request) => ({ - approvalId: request.approvalId, - tool: request.tool, - connectionId: request.connectionId, - connectionRevision: request.connectionRevision, - remoteToolName: request.remoteToolName, - })), + approvals: pending.map(({ approvalId, tool, binding }) => + binding.kind === "connection" + ? { + approvalId, + tool, + connectionId: binding.connectionId, + connectionRevision: binding.connectionRevision, + remoteToolName: binding.remoteToolName, + } + : { approvalId, tool, builtIn: true as const }, + ), modelInput, reply: suspendedReply, modelCalls: (prepared.checkpoint?.modelCalls ?? 0) + finished.modelCalls, diff --git a/packages/core/src/sandboxes/sandbox-network.ts b/packages/core/src/sandboxes/sandbox-network.ts index 2302877a..533da708 100644 --- a/packages/core/src/sandboxes/sandbox-network.ts +++ b/packages/core/src/sandboxes/sandbox-network.ts @@ -6,11 +6,13 @@ import { Context, Data, Effect, Layer } from "effect"; import type { AuthorizationDenied } from "../authorization/access.ts"; import { Authorization } from "../authorization/authorization.ts"; import type { CurrentActor } from "../authorization/current-actor.ts"; -import { query, serviceOperations } from "../database/database.ts"; +import { type Database, query, serviceOperations } from "../database/database.ts"; import { sandboxAllowedHost, sandboxBlockedHost, sandboxPodAllowedHost, + thread, + toolCall, user, } from "../database/schema.ts"; import { type UserFacing, UserMessage } from "../user-message.ts"; @@ -69,8 +71,26 @@ export interface Interface { podId: string; host: string; }) => Effect.Effect; + /** + * The workspace's block that keeps `host` out of its sandboxes, if any, so + * a request for it is refused without asking anyone. + */ + readonly blockOn: (workspaceId: string, host: string) => Effect.Effect; + /** + * Adds `host` to the pod of the turn whose call `sdkToolCallId` asked for + * it, if a person allowed that call and the workspace hasn't blocked the + * host since. + */ + readonly grantRequest: (input: { + turnId: string; + sdkToolCallId: string; + host: string; + }) => Effect.Effect; } +/** What became of an allowed request: added, refused by a block, or never allowed. */ +export type Grant = { kind: "added" } | { kind: "blocked"; by: string } | { kind: "not-allowed" }; + export class Service extends Context.Service()( "@sugabots/core/SandboxNetwork", ) {} @@ -277,6 +297,41 @@ export const make = Effect.gen(function* () { return yield* podSettingsOf(pod); }), ), + + blockOn: (workspaceId, host) => operation("blockOn", blockFor(workspaceId, host)), + + grantRequest: ({ turnId, sdkToolCallId, host }) => + operation( + "grantRequest", + Effect.gen(function* (): Effect.fn.Return { + const [call] = yield* query((db) => + db + .select({ + workspaceId: thread.workspaceId, + podId: thread.podId, + approvalStatus: toolCall.approvalStatus, + decidedById: toolCall.decidedById, + }) + .from(toolCall) + .innerJoin(thread, eq(thread.id, toolCall.threadId)) + .where(and(eq(toolCall.turnId, turnId), eq(toolCall.sdkToolCallId, sdkToolCallId))) + .limit(1), + ); + if (call?.approvalStatus !== "allowed") return { kind: "not-allowed" }; + // Blocked while the request waited for its answer. + const by = yield* blockFor(call.workspaceId, host); + if (by !== undefined) return { kind: "blocked", by }; + if (!TRUSTED_HOSTS.includes(host)) { + yield* addToPod({ + workspaceId: call.workspaceId, + podId: call.podId, + host, + addedById: call.decidedById, + }); + } + return { kind: "added" }; + }), + ), }); }); diff --git a/packages/web/src/lib/tool-approvals.ts b/packages/web/src/lib/tool-approvals.ts new file mode 100644 index 00000000..13fdcf47 --- /dev/null +++ b/packages/web/src/lib/tool-approvals.ts @@ -0,0 +1,21 @@ +import type { ThreadDetails, ToolCallPart } from "@sugabots/contracts"; + +/** What a person may decide in a thread, as the thread's details say. */ +export type ApprovalCapabilities = NonNullable; + +/** + * Whether the person may answer `call`'s approval: the pod's approvals, or, + * for a request to change the pod's sandbox, whether they manage it. + */ +export function mayAnswer(call: ToolCallPart, capabilities: ApprovalCapabilities | undefined) { + return call.approval?.deciders === "sandbox-managers" + ? (capabilities?.approveSandboxRequests ?? false) + : (capabilities?.approveToolCalls ?? false); +} + +/** Who a person who may not answer `call` is waiting for. */ +export function awaitedDeciders(call: ToolCallPart) { + return call.approval?.deciders === "sandbox-managers" + ? "Waiting for an admin of this pod to answer this." + : "Waiting for someone with permission to answer this."; +} diff --git a/packages/web/src/lib/tool-names.ts b/packages/web/src/lib/tool-names.ts index d48a36a6..16c5f299 100644 --- a/packages/web/src/lib/tool-names.ts +++ b/packages/web/src/lib/tool-names.ts @@ -8,6 +8,9 @@ import { builtInToolCatalog, CONNECTION_TOOL_SEPARATOR } from "@sugabots/contrac /** Calls to the product's own tools sit under this handle, which no connection can take. */ export const BUILT_IN_HANDLE = ""; +/** The built-in tool an agent asks with for its sandbox to reach another host. */ +export const NETWORK_REQUEST_TOOL = "request_network_access"; + /** * What a connection is called wherever one of its tools is shown. A handle with * no connection behind it any more is written out rather than shown raw, so a diff --git a/packages/web/src/screens/AgentChat.tsx b/packages/web/src/screens/AgentChat.tsx index 097e0fb4..e147af47 100644 --- a/packages/web/src/screens/AgentChat.tsx +++ b/packages/web/src/screens/AgentChat.tsx @@ -296,7 +296,7 @@ export function AgentChat({ dividers={false} onOpenThread={openThread} podId={pod.id} - canApproveToolCalls={details.capabilities?.approveToolCalls} + approvalCapabilities={details.capabilities} queued={queued} showsTyping={false} highlightedMessageId={jump.pointedOut} diff --git a/packages/web/src/screens/ChatThreadPanel.tsx b/packages/web/src/screens/ChatThreadPanel.tsx index 07ca7e12..d77c5c0b 100644 --- a/packages/web/src/screens/ChatThreadPanel.tsx +++ b/packages/web/src/screens/ChatThreadPanel.tsx @@ -180,7 +180,7 @@ export function ChatThreadPanel({ participants={mentionableIn(details)} onOpenThread={onOpenThread} podId={details.thread.podId} - canApproveToolCalls={details.capabilities?.approveToolCalls} + approvalCapabilities={details.capabilities} compact authorLinkOf={authorLinkOf} /> diff --git a/packages/web/src/screens/ThreadConversation.tsx b/packages/web/src/screens/ThreadConversation.tsx index e7751741..4ea219be 100644 --- a/packages/web/src/screens/ThreadConversation.tsx +++ b/packages/web/src/screens/ThreadConversation.tsx @@ -15,6 +15,7 @@ import { Fragment, type MouseEvent, type ReactNode, useRef, useState } from "rea import type { AuthorLink, PagedAuthor } from "@/lib/author-links.ts"; import { useConnectionLooks } from "@/lib/connections.ts"; import { formatClockTime } from "@/lib/list-time.ts"; +import { type ApprovalCapabilities, mayAnswer } from "@/lib/tool-approvals.ts"; import { splitToolKey } from "@/lib/tool-names.ts"; import { AgentAvatar } from "@/shell/Agent.tsx"; import { PersonAvatar } from "@/ui/avatar.tsx"; @@ -60,7 +61,7 @@ export function ThreadConversation({ dividers = true, onOpenThread, podId, - canApproveToolCalls = false, + approvalCapabilities, compact = false, queued = NONE_QUEUED, peopleTyping = [], @@ -88,7 +89,8 @@ export function ThreadConversation({ */ onOpenThread: (threadId: string) => void; podId: string; - canApproveToolCalls?: boolean; + /** What the person may decide of the approvals the thread raises. */ + approvalCapabilities?: ApprovalCapabilities; /** The sidebar's narrower thread: smaller faces and words. */ compact?: boolean; /** @@ -143,7 +145,7 @@ export function ThreadConversation({ participants={participants} looks={looks} podId={podId} - canApproveToolCalls={canApproveToolCalls} + approvalCapabilities={approvalCapabilities} compact={compact} arrivedLive={watchedWritten.has(message.id)} queued={queued.has(message.id)} @@ -319,7 +321,7 @@ function MessageRow({ participants, looks, podId, - canApproveToolCalls, + approvalCapabilities, compact, arrivedLive, queued, @@ -335,7 +337,7 @@ function MessageRow({ participants: ThreadParticipant[]; looks: ReturnType; podId: string; - canApproveToolCalls: boolean; + approvalCapabilities: ApprovalCapabilities | undefined; compact: boolean; /** Finished while the thread was open, so it arrives rather than simply being there. */ arrivedLive: boolean; @@ -450,7 +452,7 @@ function MessageRow({ agent={agent} threadId={message.threadId} podId={podId} - canApprove={canApproveToolCalls} + canApprove={mayAnswer(call, approvalCapabilities)} look={looks.get(splitToolKey(call.tool).handle)} /> diff --git a/packages/web/src/screens/ToolApprovalCard.stories.tsx b/packages/web/src/screens/ToolApprovalCard.stories.tsx index 6f018943..dc82ccd6 100644 --- a/packages/web/src/screens/ToolApprovalCard.stories.tsx +++ b/packages/web/src/screens/ToolApprovalCard.stories.tsx @@ -181,3 +181,38 @@ export const ReviewOnAPhoneAsAViewer = meta.story({ await expect(await screen.findByRole("dialog", { name: "Create event" })).toBeInTheDocument(); }, }); + +/** ANetworkRequest is an agent asking for its pod's sandbox to reach a host, which an admin of the pod answers. */ +export const ANetworkRequest = meta.story({ + args: { + call: asking({ + tool: "request_network_access", + input: { + host: "api.stripe.com", + }, + approval: { + status: "pending", + deciders: "sandbox-managers", + decidedByName: null, + decidedAt: null, + }, + }), + look: undefined, + }, + play: async ({ canvas }) => { + await expect( + canvas.getByText("Trip Planner wants its sandbox to reach a new host"), + ).toBeInTheDocument(); + await expect(canvas.queryByText("RN")).toBeNull(); + }, +}); + +/** ANetworkRequestNotYours is that request seen by someone who doesn't decide the pod's network access. */ +export const ANetworkRequestNotYours = meta.story({ + args: { ...ANetworkRequest.input.args, canApprove: false }, + play: async ({ canvas }) => { + await expect( + canvas.getByText("Waiting for an admin of this pod to answer this."), + ).toBeInTheDocument(); + }, +}); diff --git a/packages/web/src/screens/ToolApprovalCard.tsx b/packages/web/src/screens/ToolApprovalCard.tsx index 53b03e69..1474b0e2 100644 --- a/packages/web/src/screens/ToolApprovalCard.tsx +++ b/packages/web/src/screens/ToolApprovalCard.tsx @@ -1,12 +1,19 @@ import { botColorVariables } from "@sugabots/avatars"; import type { ThreadParticipant, ToolCallPart } from "@sugabots/contracts"; import { cn } from "cn"; -import { Check, ChevronDown, X } from "lucide-react"; +import { Check, ChevronDown, Globe, X } from "lucide-react"; import { Fragment, type ReactNode, useId, useLayoutEffect, useRef, useState } from "react"; import type { ConnectionLook } from "@/lib/connections.ts"; import { failureMessage } from "@/lib/failure.ts"; import { useReviewToolCall } from "@/lib/threads.ts"; -import { connectionLabel, splitToolKey, stepLabel, wordsFromKey } from "@/lib/tool-names.ts"; +import { awaitedDeciders } from "@/lib/tool-approvals.ts"; +import { + connectionLabel, + NETWORK_REQUEST_TOOL, + splitToolKey, + stepLabel, + wordsFromKey, +} from "@/lib/tool-names.ts"; import { AgentAvatar } from "@/shell/Agent.tsx"; import { Button } from "@/ui/button.tsx"; import { ConnectionMark } from "@/ui/connection-mark.tsx"; @@ -52,11 +59,15 @@ export function ToolApprovalCard({ const { handle, name } = splitToolKey(call.tool); const where = handle ? connectionLabel(handle, look?.name) : ""; const action = stepLabel(call.tool, name); + const networkRequest = call.tool === NETWORK_REQUEST_TOOL; const step: Step = { action, - who: `${agent.name} wants to use ${where || "a tool"}`, + who: networkRequest + ? `${agent.name} wants its sandbox to reach a new host` + : `${agent.name} wants to use ${where || "a tool"}`, where, look, + networkRequest, }; const answerable = awaitsApproval(call) && canApprove; // The answer stays given from when it is sent until the thread's events say @@ -70,7 +81,7 @@ export function ToolApprovalCard({ const status = awaitsApproval(call) && !canApprove ? (

- Waiting for someone with permission to answer this. + {awaitedDeciders(call)}

) : decided?.status === "allowed" ? ( }> @@ -176,6 +187,31 @@ interface Step { who: string; where: string; look: ConnectionLook | undefined; + /** A request for the sandbox to reach another host, which no connection makes. */ + networkRequest: boolean; +} + +const MARK_SIZES = { + md: { box: "size-6 rounded-[7px]", icon: 14 }, + sm: { box: "size-8 rounded-lg", icon: 16 }, +} as const; + +/** What the step uses: the connection's mark, or a globe for a network request. */ +function StepMark({ step, size }: { step: Step; size: keyof typeof MARK_SIZES }) { + if (!step.networkRequest) { + return ( + + ); + } + const { box, icon } = MARK_SIZES[size]; + return ( + + + + ); } /** The connection's mark beside who wants to use it, and what they would do. */ @@ -183,7 +219,7 @@ function StepHeading({ step }: { step: Step }) { return (
- + {step.who} @@ -344,11 +380,7 @@ function ReviewHeading({ - +

undefined} dividers={false} - canApproveToolCalls={over.canApprove ?? true} + approvalCapabilities={{ approveToolCalls: over.canApprove ?? true }} /> );