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
15 changes: 14 additions & 1 deletion packages/contracts/src/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}),
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/authorization/access.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<PodStanding, "may">,
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"];
Expand Down
28 changes: 21 additions & 7 deletions packages/core/src/conversations/chats/chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ChatMessagesPage,
ChatPageQuery,
PodChatMarkers,
ToolApprovalDeciders,
} from "@sugabots/contracts";
import {
DEFAULT_CHAT_PAGE_LIMIT,
Expand Down Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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. */
Expand All @@ -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)))
Expand Down Expand Up @@ -917,15 +930,16 @@ 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))
.where(and(inArray(asking.chatId, [...chatIds]), eq(toolCall.approvalStatus, "pending")));
if (pending.length === 0) return new Set<string>();
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] : [],
),
);
});
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/conversations/chats/chats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/conversations/routines/routines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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: [] },
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/conversations/thread-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/conversations/threads/tool-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -50,6 +51,7 @@ export function toToolCallPart(
approval: row.approvalStatus
? {
status: row.approvalStatus,
deciders: decidersOf(row.tool),
decidedByName,
decidedAt: row.decidedAt?.toISOString() ?? null,
}
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/conversations/tools/approval-deciders.ts
Original file line number Diff line number Diff line change
@@ -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";
}
68 changes: 68 additions & 0 deletions packages/core/src/conversations/tools/network-access/tool.ts
Original file line number Diff line number Diff line change
@@ -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<SandboxNetwork.Interface, "grantRequest">;
blocked: readonly string[];
run: RunEffect<never>;
}): 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;
}
Loading
Loading