diff --git a/.env.example b/.env.example index f25c4b7..465d645 100644 --- a/.env.example +++ b/.env.example @@ -123,17 +123,33 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # What a Bot may do on its computer, as one JSON object. Absent uses the built-in default, which # permits the acting tools and forbids nothing, and records every action either way. # -# `deny` is evaluated first and beats `allow`. An empty `allow` permits nothing, a missing policy +# `deny` is evaluated first and beats everything. An empty `allow` permits nothing, a missing policy # permits nothing, and a rule that fails to parse denies rather than letting the action through. The # server refuses to start if this is set and malformed, so an invalid restriction never falls back to # permissive behavior. # +# `ask` is the third list, checked after `deny` and before `allow`. A match stops the Bot, puts the +# action in front of a person in the conversation, and carries on with the same action if they allow +# it, so the turn is not thrown away. Nothing an `ask` rule matches can be reached by a `deny` rule: +# forbidden stays forbidden and is never offered as a question. It has to beat `allow`, because the +# default below permits everything, and an ask checked afterwards would never fire. +# +# An answer is bound to the exact action it was given for, so allowing one button is not permission +# to press a different one, and it can only be spent once. Nobody answering within ten minutes is the +# same as nobody being asked: the action does not happen. In `dry-run` an ask interrupts nobody and is +# only recorded, because dry-run promises to change nothing. +# +# Every list judges a Bot's calls to MCP servers as well as what it does in a browser, `ask` included, +# so a rule like `intent == "write_tool" && mcp.server == "jira"` stops the call and asks rather than +# refusing it. The questions and the answers are the same three audit rows either way. +# # Workspace and browser profile per Bot. Each Bot's computer is its own container with its own # volumes, so one Bot cannot read another's files or use another's logins, and every action records # which Bot took it. A rule can still restrict a single Bot with `bot.id`. # # Attributes: tool.name, bot.id, actor.id, page.url, page.host, element.ref/role/name/type, -# key, file.path, file.name, file.extension, repeat.count. +# key, submit, file.path, file.name, file.extension, mcp.server/tool/effect, +# repeat.count. # # repeat.count is how many times this Bot has just made this exact call, counting the one being # decided. A stuck model retries, and each retry is a real action on somebody's live website that is @@ -146,13 +162,14 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # another server's tools over MCP are not counted at all. # # Name every route to the same effect. A form submits from a keypress in any of its fields, so a rule -# that only blocks a Submit button does not block Enter from another field. The example below refuses -# Enter outright for that reason. +# that only blocks a Submit button does not block Enter from another field, and a Bot can ask the type +# tool to press Enter for it, which arrives as `submit` rather than as a keypress. The example below +# names all three. # Functions: contains(haystack, needle) and matches(value, pattern), both case-insensitive. # `enforce` blocks; `dry-run` decides and records but lets everything through, so a new rule can be # tried against real traffic before it starts refusing anybody's work. # -# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"allow":["true"]} +# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\") || submit"],"ask":["intent == \"write_file\" && !matches(file.path, \"^notes/\")"],"allow":["true"]} # How long two identical calls count as the same repetition, in ms. Three minutes unset, which # assumes a retry loop is a model round trip apart: call the tool, read the failure, try again. diff --git a/app/src/components/channels/approval-request.tsx b/app/src/components/channels/approval-request.tsx new file mode 100644 index 0000000..abc1bdb --- /dev/null +++ b/app/src/components/channels/approval-request.tsx @@ -0,0 +1,99 @@ +import { useCallback, useState, useSyncExternalStore } from "react"; +import { Button } from "@/components/ui/button"; +import { + answerApproval, + closeQuestion, + questionOn, + watchQuestions, +} from "@/lib/approvals"; + +/** + * A transcript line that grew two buttons, for the one action a boundary wanted a person to see. + * + * Not a modal, and the restraint is the point. A question about one click belongs where the click is + * being reported, in sequence with everything else the Bot did, so a person can see what led up to it + * without losing the conversation behind a dialog. A boundary that interrupts the whole screen is one + * people learn to dismiss, and an ask rule that gets reflexively approved is worse than no rule at + * all: it produces a record of consent that nobody actually gave. + * + * It draws the question its own tool call is waiting on, and nothing else. The alternative, asking + * the server what this Bot is waiting on and showing the first unanswered thing, cannot tell one + * question from another: a run that was stopped or a tab that was reloaded leaves its question open + * in the registry for the rest of the ten minutes, so the card would offer somebody a stale question + * on the line of an action nobody is being asked about, and record their Allow against the wrong + * one. The tool call that raised the question is the only thing that knows which one is its own, so + * it is what says so. + */ +export function ApprovalRequest({ + /** The tool call this line is reporting. Undefined before the SDK has named it. */ + toolCallId, +}: { + toolCallId: string | undefined; +}) { + const asking = useSyncExternalStore(watchQuestions, () => + questionOn(toolCallId ?? ""), + ); + const [answering, setAnswering] = useState(false); + const [problem, setProblem] = useState(null); + + const answer = useCallback( + async (granted: boolean) => { + if (!asking) return; + setAnswering(true); + const result = await answerApproval( + asking.botId, + asking.approvalId, + granted, + ); + setAnswering(false); + if (!result.ok) { + setProblem(result.error ?? "That answer could not be recorded."); + return; + } + // Taken down here rather than waiting for the call to notice, so the buttons stop being + // pressable the moment the answer lands. The Bot's turn is still on the server working out + // what to do with it. + closeQuestion(toolCallId ?? ""); + setProblem(null); + }, + [asking, toolCallId], + ); + + if (!asking) return null; + + return ( +
+

{asking.question}

+ {asking.rule ? ( +

+ {asking.rule} +

+ ) : null} +
+ + + + Asked because of this rule. Allowing covers this one action. + +
+ {problem ? ( +

+ {problem} +

+ ) : null} +
+ ); +} diff --git a/app/src/lib/approvals.ts b/app/src/lib/approvals.ts new file mode 100644 index 0000000..cf84610 --- /dev/null +++ b/app/src/lib/approvals.ts @@ -0,0 +1,163 @@ +/** + * The questions a boundary raised, from the browser's side: reading them, answering them, and + * knowing which tool call each one belongs to. + * + * One module for all of it because the two halves have to agree. A tool call that met an `ask` rule + * holds itself open waiting for an answer, and the card a person answers on is drawn on that same + * tool call's line in the transcript. Those are different components on different render passes, so + * the id travels through here. + * + * A question is held against the tool call that raised it rather than against the Bot. The Bot's + * list is the wrong key: nothing withdraws a question when the wait around it ends, so pressing + * Stop, reloading the tab or a turn that errors all leave an unanswered entry sitting in the + * server's registry until it expires. A card that showed "the oldest thing this Bot is waiting on" + * would then put a stale question in front of somebody on an unrelated line, record their Allow + * against an action nobody is waiting for, and leave the action they were actually looking at + * waiting out the full ten minutes. + */ + +/** + * How long the surface holds a tool call open for an answer, and how often it looks. + * + * Ten minutes matches the server's own window, so the wait ends because the question expired rather + * than because the two sides disagreed about when it had. + */ +const WAIT_FOR_ANSWER_MS = 10 * 60_000; +const WAIT_POLL_MS = 1_000; + +export type PendingApproval = { + id: string; + botId: string; + /** The expression that asked, shown as a rule so a person can see which boundary they are at. */ + rule: string; + /** What is about to happen, in one sentence. */ + question: string; + requestedAt: string; + expiresAt: string; + /** Absent while nobody has answered. False is an answer. */ + granted?: boolean; + answeredBy?: string; +}; + +/** A question one tool call is waiting on, as its own line in the transcript needs to draw it. */ +export type OpenQuestion = { + approvalId: string; + botId: string; + question: string; + rule: string | null; +}; + +const open = new Map(); +const watchers = new Set<() => void>(); + +/** + * Say that this tool call is waiting on an answer, so its line can draw the card. + * + * Handed over rather than fetched again: the server said all of it in the reply that paused the + * call, and a card that re-derived its question from a list would be back to guessing which entry + * in that list was its own. + */ +export function openQuestion(toolCallId: string, question: OpenQuestion): void { + if (!toolCallId) return; + open.set(toolCallId, question); + for (const watcher of watchers) watcher(); +} + +/** The wait is over, whichever way it went. Nothing should still be offering buttons for it. */ +export function closeQuestion(toolCallId: string): void { + if (!open.delete(toolCallId)) return; + for (const watcher of watchers) watcher(); +} + +export function questionOn(toolCallId: string): OpenQuestion | undefined { + return open.get(toolCallId); +} + +export function watchQuestions(listener: () => void): () => void { + watchers.add(listener); + return () => { + watchers.delete(listener); + }; +} + +/** + * The open questions for one Bot, or null if the server could not be asked. + * + * Null and an empty list are kept apart on purpose. A caller waiting for its own answer must not read + * a failed request as "the question is gone", which is what an empty list means here. + */ +export async function readApprovals( + botId: string, +): Promise { + try { + const response = await fetch(`/api/approvals/${botId}`, { + credentials: "include", + }); + if (!response.ok) return null; + const body = (await response.json()) as { approvals?: PendingApproval[] }; + return body.approvals ?? []; + } catch { + return null; + } +} + +export async function answerApproval( + botId: string, + approvalId: string, + granted: boolean, +): Promise<{ ok: boolean; error?: string }> { + try { + const response = await fetch(`/api/approvals/${botId}/${approvalId}`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ granted }), + }); + if (response.ok) return { ok: true }; + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + return { + ok: false, + error: body?.error ?? "That answer could not be recorded.", + }; + } catch { + return { + ok: false, + error: "The assistant's computer could not be reached.", + }; + } +} + +/** + * Hold a tool call open until somebody answers its question. + * + * Polled rather than pushed. The answer arrives on a server this tab has no other channel to, and it + * may well be given in a different tab or by a different person, so the only honest way to learn it + * is to keep asking. A second between looks costs one request while a Bot is stopped and nothing at + * all the rest of the time. + */ +export async function waitForApproval( + botId: string, + approvalId: string, + signal: AbortSignal | undefined, +): Promise<"granted" | "declined" | "gave up" | "cancelled"> { + const deadline = Date.now() + WAIT_FOR_ANSWER_MS; + while (Date.now() < deadline) { + // Stop must work out of this wait as well, or pressing it leaves a Bot parked on a question + // nobody is going to answer. + if (signal?.aborted) return "cancelled"; + const approvals = await readApprovals(botId); + if (approvals) { + const mine = approvals.find((one) => one.id === approvalId); + // Gone from a list we did read means it expired and was swept, which is the same outcome as + // running out of patience here. A list we could NOT read says nothing, so it is not read as an + // answer. + if (!mine) return "gave up"; + if (mine.granted === true) return "granted"; + if (mine.granted === false) return "declined"; + } + await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)); + } + return "gave up"; +} diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index ce480b1..bea0484 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -1,11 +1,13 @@ import { useFrontendTool } from "@copilotkit/react-core/v2"; import { z } from "zod"; +import { ApprovalRequest } from "@/components/channels/approval-request"; import { ToolLine } from "@/components/channels/tool-line"; import { ComputerView } from "@/components/computer/computer-view"; import { type ControlState, readControl, } from "@/components/computer/take-the-wheel"; +import { closeQuestion, openQuestion, waitForApproval } from "@/lib/approvals"; import { useActiveBotHolder } from "./active-bot"; import { reportComputerActivity } from "./computer-activity"; @@ -16,6 +18,16 @@ import { reportComputerActivity } from "./computer-activity"; /** What every computer call returns to the model: either the result, or a reason it did not happen. */ type ToolOutcome = Record & { ok: boolean }; +/** + * What the SDK hands a running tool call, as much of it as this file needs. + * + * Passed around whole rather than unpicked into an abort signal, because the id matters as much as + * the abort does: it is what lets a question about this call be drawn on this call's line and + * nowhere else. Optional throughout, because the SDK's context argument is optional and a handler + * that destructures it unconditionally throws on any call that omits it. + */ +type ToolCallContext = { signal?: AbortSignal; toolCall?: { id?: string } }; + /** * Human-assistance wait window. Long enough for a user to return, finite so the run can unblock. */ @@ -42,7 +54,91 @@ async function waitForPerson( return "gave up"; } +/** + * The same request, carrying an answer. + * + * Rebuilt rather than mutated, because every acting route is a POST of one JSON object and the + * approval is one more field on it. Sending the identical arguments matters: the server binds an + * approval to a fingerprint of the action, so a retry that differed in any way it hashes would be + * refused as a different action, which is exactly what that binding is for. + */ +function withApproval( + init: RequestInit | undefined, + approvalId: string, +): RequestInit { + const sent = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : {}; + return { + ...init, + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...sent, approvalId }), + }; +} + +/** + * One computer call, including the pause where a person is asked about it. + * + * The waiting lives here rather than in each tool's handler so that a tool cannot be added without + * it: an acting route that met an ask rule and got back a bare failure would report to the model + * that the action was impossible, when in fact nobody had been asked yet. + */ async function callComputer( + botId: string, + path: string, + init?: RequestInit, + call: ToolCallContext = {}, +): Promise { + const signal = call.signal; + const outcome = await sendToComputer(botId, path, init, signal); + if (outcome.awaitingApproval !== true) return outcome; + + const approvalId = String(outcome.approvalId ?? ""); + // Put in front of the person on this call's own line rather than left to be found. The id is what + // ties the card to the action it is about; see lib/approvals.ts for why anything looser gets it + // wrong. + const toolCallId = call.toolCall?.id ?? ""; + openQuestion(toolCallId, { + approvalId, + botId, + question: String(outcome.question ?? outcome.reason ?? ""), + rule: typeof outcome.rule === "string" ? outcome.rule : null, + }); + try { + const answer = await waitForApproval(botId, approvalId, signal); + if (answer === "granted") { + // Sent once, not through this function again. A second ask on the retry would mean the approval + // did not fit the action, and looping on that would hold the turn open until the deadline instead + // of telling the model something it can act on. + return sendToComputer( + botId, + path, + withApproval(init, approvalId), + signal, + ); + } + if (answer === "cancelled") { + return { ok: false, reason: "Stopped.", stopped: true }; + } + return answer === "declined" + ? { ok: false, refused: true, reason: "A person declined that." } + : { + ok: false, + reason: + "Nobody answered the request to allow that, so it did not happen. Say what you were " + + "waiting for rather than trying another way round it.", + }; + } finally { + // However the wait ended, nothing should still be offering buttons for it. A run that was + // stopped leaves its question open on the server for the rest of its ten minutes, and a card + // that outlived its own call would be collecting consent nobody is waiting for. + closeQuestion(toolCallId); + } +} + +async function sendToComputer( botId: string, path: string, init?: RequestInit, @@ -75,6 +171,19 @@ async function callComputer( > | null; if (!response.ok) { + // Read before anything else a 409 can mean. The other two, stale refs and a person holding the + // wheel, are conditions the model reacts to; this one it must not see at all, because the caller + // above is going to wait and then send the very same request again. + if (response.status === 409 && body?.awaitingApproval === true) { + return { + ok: false, + awaitingApproval: true, + approvalId: body.approvalId ?? "", + question: body.question ?? "", + rule: body.rule ?? null, + reason: (body.error as string) ?? "Somebody is being asked about that.", + }; + } return { ok: false, reason: (body?.error as string) ?? "That did not work.", @@ -140,14 +249,27 @@ function labelOf(result: string | undefined): string | undefined { /** * A compact transcript line that distinguishes policy refusals from ordinary failures. + * + * While the action is still running it also carries the place a question about it appears. The card + * belongs on the line for the action it is about, in sequence, rather than somewhere else on the + * screen: a person deciding whether to allow a click wants to see what the Bot did to get there. */ function ActionLine({ + toolCallId, label, detail, running, refused, failed, }: { + /** + * Which call this line is reporting. + * + * The card shows a question only when this exact call raised one, so a line for an action nobody + * was asked about stays a line. Passing the Bot instead would draw whatever that Bot happened to + * be waiting on, which on a second turn is somebody else's abandoned question. + */ + toolCallId?: string; label: string; detail?: string; running?: boolean; @@ -157,13 +279,16 @@ function ActionLine({ failed?: boolean; }) { return ( - + <> + + + ); } @@ -184,11 +309,7 @@ export function ComputerTools() { parameters: z.object({ url: z.string().describe("Full web address to open, including https://"), }), - handler: async ( - { url }: { url: string }, - // Context is optional in the SDK. - { signal }: { signal?: AbortSignal } = {}, - ) => { + handler: async ({ url }: { url: string }, call: ToolCallContext = {}) => { const result = await callComputer( bot.current, "/navigate", @@ -197,7 +318,7 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify({ url }), }, - signal, + call, ); return result.ok ? { @@ -209,8 +330,14 @@ export function ComputerTools() { } : result; }, - render: ({ status }) => ( + render: ({ status, toolCallId }) => (
+ {/* + * Above the screen rather than through ActionLine, because opening a page draws the live + * view instead of a line. A question about where the Bot is about to go still belongs + * beside it. + */} +
), @@ -278,7 +405,7 @@ export function ComputerTools() { text: string; submit?: boolean; }, - { signal }: { signal?: AbortSignal } = {}, + call: ToolCallContext = {}, ) => callComputer( bot.current, @@ -288,10 +415,11 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - signal, + call, ), - render: ({ args, result, status }) => ( + render: ({ args, result, status, toolCallId }) => ( callComputer( bot.current, @@ -330,12 +458,13 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - signal, + call, ), - render: ({ args, result, status }) => { + render: ({ args, result, status, toolCallId }) => { const outcome = outcomeOf(result); return ( callComputer( bot.current, @@ -381,10 +510,11 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - signal, + call, ), - render: ({ args, result, status }) => ( + render: ({ args, result, status, toolCallId }) => ( { const botId = bot.current; const asked = await callComputer( @@ -429,7 +559,7 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - signal, + call, ); if (!asked.ok) return asked; @@ -437,7 +567,7 @@ export function ComputerTools() { const outcome = await waitForPerson( botId, (state) => state.secretWanted === undefined, - signal, + call.signal, ); return { ok: true, @@ -473,7 +603,7 @@ export function ComputerTools() { }), handler: async ( input: { reason: string; request?: string }, - { signal }: { signal?: AbortSignal } = {}, + call: ToolCallContext = {}, ) => { try { const response = await fetch( @@ -483,7 +613,7 @@ export function ComputerTools() { credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify(input), - ...(signal ? { signal } : {}), + ...(call.signal ? { signal: call.signal } : {}), }, ); return response.ok @@ -511,10 +641,7 @@ export function ComputerTools() { "What you need the person to do, in one sentence, e.g. 'This page is asking for a code sent to your phone.'", ), }), - handler: async ( - input: { reason: string }, - { signal }: { signal?: AbortSignal } = {}, - ) => { + handler: async (input: { reason: string }, call: ToolCallContext = {}) => { const botId = bot.current; const asked = await callComputer( botId, @@ -524,7 +651,7 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - signal, + call, ); if (!asked.ok) return asked; @@ -532,7 +659,7 @@ export function ComputerTools() { const outcome = await waitForPerson( botId, (state) => state.holder === "bot" && !state.requested, - signal, + call.signal, ); return { ok: true, @@ -560,17 +687,23 @@ export function ComputerTools() { .optional() .describe("Optional folder to list. Omit for the whole workspace."), }), - handler: async (input: { path?: string }) => - callComputer(bot.current, "/files/list", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input ?? {}), - }), - render: ({ result, status }) => { + handler: async (input: { path?: string }, call: ToolCallContext = {}) => + callComputer( + bot.current, + "/files/list", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input ?? {}), + }, + call, + ), + render: ({ result, status, toolCallId }) => { const outcome = outcomeOf(result); const entries = Array.isArray(outcome.entries) ? outcome.entries : []; return ( - callComputer(bot.current, "/files/read", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - }), - render: ({ args, result, status }) => { + handler: async (input: { path: string }, call: ToolCallContext = {}) => + callComputer( + bot.current, + "/files/read", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + call, + ), + render: ({ args, result, status, toolCallId }) => { const outcome = outcomeOf(result); return ( - callComputer(bot.current, "/files/write", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - }), - render: ({ args, result, status }) => { + handler: async ( + input: { + path: string; + contents: string; + append?: boolean; + }, + call: ToolCallContext = {}, + ) => + callComputer( + bot.current, + "/files/write", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + call, + ), + render: ({ args, result, status, toolCallId }) => { const outcome = outcomeOf(result); return ( + handler: async (input: { deltaY?: number }, call: ToolCallContext = {}) => callComputer( bot.current, "/scroll", @@ -695,10 +840,11 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - signal, + call, ), - render: ({ result, status }) => ( + render: ({ result, status, toolCallId }) => ( - {result ? ( - /* The server's own words, drawn the way a Bot's prose is drawn. */ - - {forDisplay(result.text)} - - ) : null} -
+ <> + {/* + * A boundary can stop a tool call the same way it stops a click, so the question belongs + * on this line rather than only on the lines about a browser. + */} + + + {result ? ( + /* The server's own words, drawn the way a Bot's prose is drawn. */ + + {forDisplay(result.text)} + + ) : null} + + ); }, }); diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index aad4c73..a74d6ea 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { closeQuestion, openQuestion, waitForApproval } from "@/lib/approvals"; /** A tool one server offers, as the Plugins page sees it. */ export type PluginTool = { @@ -121,18 +122,100 @@ export type PluginCallOutcome = /** * Call a tool as a Bot, with server-side grant and policy rechecks for mid-run revocations. + * + * A call the boundary wants a person's answer to comes back here as a pause rather than a failure, + * and this holds it open, puts the question on the tool call's own line, and sends the identical + * call again with the answer attached. A tool call that reported "not allowed" to the model instead + * would throw away the turn on work the deployment was willing to permit, which is the whole + * difference between an ask rule and a deny rule. */ export async function callPluginTool( ref: string, args: Record, agentId: string, signal?: AbortSignal, + toolCallId?: string, ): Promise { + const outcome = await sendCall(ref, args, agentId, signal); + if (!("awaitingApproval" in outcome)) return outcome; + + openQuestion(toolCallId ?? "", { + approvalId: outcome.approvalId, + botId: agentId, + question: outcome.question, + rule: outcome.rule, + }); + try { + const answer = await waitForApproval(agentId, outcome.approvalId, signal); + if (answer === "granted") { + // Sent once, not through this function again. A second ask on the retry would mean the answer + // did not fit the call, and looping on that would hold the turn open until the deadline + // instead of telling the model something it can act on, so a question raised on the retry is + // reported rather than waited on. + const retried = await sendCall( + ref, + args, + agentId, + signal, + outcome.approvalId, + ); + return "awaitingApproval" in retried + ? { + ok: false, + refused: false, + reason: + "That was allowed, but the answer did not fit the call being made, so it did not happen.", + } + : retried; + } + return answer === "declined" + ? { + ok: false, + refused: true, + reason: "A person declined that.", + rule: outcome.rule, + } + : { + ok: false, + refused: false, + reason: + answer === "cancelled" + ? "Stopped." + : "Nobody answered the request to allow that, so it did not happen. Say what you " + + "were waiting for rather than trying another way round it.", + }; + } finally { + closeQuestion(toolCallId ?? ""); + } +} + +/** A question the server raised about this call, as the reply that paused it carried it. */ +type AwaitingApproval = { + awaitingApproval: true; + approvalId: string; + question: string; + rule: string | null; +}; + +async function sendCall( + ref: string, + args: Record, + agentId: string, + signal?: AbortSignal, + approvalId?: string, +): Promise { const response = await fetch("/api/plugins/call", { method: "POST", credentials: "include", headers: { "content-type": "application/json" }, - body: JSON.stringify({ ref, args, agentId }), + body: JSON.stringify({ + ref, + args, + agentId, + // Sent identically to the call that was asked about, because the server binds an answer to a + // fingerprint of the call, arguments included: anything else comes back as a different action. + ...(approvalId ? { approvalId } : {}), + }), ...(signal ? { signal } : {}), }); @@ -141,6 +224,9 @@ export async function callPluginTool( isError?: boolean; error?: string; rule?: string | null; + awaitingApproval?: boolean; + approvalId?: string; + question?: string; } | null; if (response.ok) { @@ -150,6 +236,16 @@ export async function callPluginTool( isError: body?.isError === true, }; } + // Read before anything else a 409 can mean, and never shown to the model: the caller above is + // going to wait and then send the very same call again. + if (response.status === 409 && body?.awaitingApproval === true) { + return { + awaitingApproval: true, + approvalId: body.approvalId ?? "", + question: body.question ?? body.error ?? "", + rule: body.rule ?? null, + }; + } if (response.status === 403) { return { ok: false, diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx index a6051ab..84a7b80 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -35,9 +35,11 @@ const FILTERS = [ { label: "Computer actions", search: "?eventType=computer.action_allowed" }, { label: "Blocked", - // Include every refusal family, not only browser policy refusals. + // Include every refusal family, not only browser policy refusals. A person declining a request + // stopped an action just as surely as a deny rule did, and it leaves no action row of its own, + // so without it here the trail's answer to "was anything blocked" is missing a whole family. search: - "?eventType=computer.action_refused,mcp.call_rejected,component.refused,component.function_refused", + "?eventType=computer.action_refused,approval.denied,mcp.call_rejected,component.refused,component.function_refused", }, { label: "Did not happen", @@ -45,6 +47,15 @@ const FILTERS = [ // did not take: nothing was refused, and nothing came of it either. search: "?eventType=computer.action_failed,agent.stream_stalled", }, + { + /* + * The questions, so the one a person never answered can be found rather than looked for by eye. + * A request with no answer beside it is the Bot having sat waiting while nobody was watching the + * screen, which is the case this trail records that nothing else in the product would show. + */ + label: "Asked a person", + search: "?eventType=approval.requested,approval.granted,approval.denied", + }, { // Its own filter rather than a place in "Blocked". A Bot repeating itself has not been stopped by // anything, and putting it beside the refusals would make the refusals look less real. @@ -138,6 +149,7 @@ function Row({ allowed?: boolean; mode?: string; rule?: string | null; + approvedBy?: string; carriedOut?: boolean; }; const element = payload.element as @@ -146,9 +158,13 @@ function Row({ | undefined; const refused = event.eventType === "computer.action_refused" || + event.eventType === "approval.denied" || event.eventType === "component.refused" || event.eventType === "component.function_refused" || event.eventType === "mcp.call_rejected"; + // The three rows a question leaves behind carry their rule at the top level rather than under a + // decision, because no decision was reached: the policy stopped and waited for a person. + const approval = event.eventType.startsWith("approval."); const stalled = event.eventType === "agent.stream_stalled"; // Allowed by policy but not carried out. A stalled turn belongs in the same family: the Bot was // asked and the answer never arrived. Colour is how this table is read, and a row left in the @@ -254,6 +270,11 @@ function Row({ {payload.failure} ) : null} + {approval && typeof payload.reason === "string" ? ( +
+ {payload.reason} +
+ ) : null} {/* * The two numbers the stall row is worth reading for. Without them every stalled turn looks * the same, and the difference between an endpoint that dies halfway through an answer and @@ -268,6 +289,17 @@ function Row({ {decision.rule} ) : null} + {approval && typeof payload.rule === "string" && payload.rule ? ( +
+ {payload.rule} +
+ ) : null} + {/* Who stood behind an action, when the boundary asked and somebody said yes. */} + {typeof decision.approvedBy === "string" ? ( +
+ allowed by {decision.approvedBy} +
+ ) : null} {decision.mode === "dry-run" && decision.carriedOut ? (
dry-run: recorded, not enforced @@ -307,6 +339,9 @@ const DECISIONS: Record = { "computer.stopped": "A person pressed stop", // Not "Blocked". Nothing refused this; the Bot did the same thing again and the trail is saying so. "computer.action_repeated": "The Bot repeated itself", + "approval.requested": "The boundary asked a person", + "approval.granted": "A person allowed it", + "approval.denied": "A person declined it", "component.granted": "Granted to this Bot", "component.revoked": "Taken away from this Bot", diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx index e8e7629..7f7470c 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -14,17 +14,21 @@ type PolicyMode = "dry-run" | "enforce"; type ActionPolicy = { mode: PolicyMode; deny: string[]; + ask: string[]; allow: string[]; }; +type Preset = { label: string; rule: string; cost?: string }; + /** * Presets are concrete CEL rules, not a separate policy language. */ -const PRESETS: { label: string; rule: string; cost?: string }[] = [ +const PRESETS: Preset[] = [ { label: "Never submit a form", - // `key` exists only on keypress actions; guard it by tool name to keep other actions evaluable. - rule: '(intent == "activate" && contains(element.name, "submit")) || (tool.name == "computer_key" && key == "Enter")', + // Three doors, not two. `key` exists only on keypress actions, so it is guarded by tool name to + // keep the rule evaluable elsewhere; `submit` is on every action and needs no guard. + rule: '(intent == "activate" && contains(element.name, "submit")) || (tool.name == "computer_key" && key == "Enter") || submit', cost: "Also stops the Bot pressing Enter for anything else, because a form submits from Enter in any of its fields.", }, { @@ -45,6 +49,26 @@ const PRESETS: { label: string; rule: string; cost?: string }[] = [ }, ]; +/** + * The same rules a deployment might otherwise have had to forbid outright. + * + * Both of these are things a Bot is genuinely useful for and that nobody wants it doing unwatched + * the first few times, which is the whole shape of this list: the boundary an operator actually + * wants is rarely "never", it is "not without me". + */ +const ASK_PRESETS: Preset[] = [ + { + label: "Ask before submitting a form", + rule: '(intent == "activate" && contains(element.name, "submit")) || (tool.name == "computer_key" && key == "Enter") || submit', + cost: "Asks about every Enter the Bot presses, because a form submits from Enter in any of its fields. Expect to be asked while it is filling one in, not only at the end.", + }, + { + label: "Ask before writing a file outside notes/", + rule: 'intent == "write_file" && !matches(file.path, "^notes/")', + cost: "Matches on the path the Bot asked for, so a folder it has not used before is a question rather than a refusal.", + }, +]; + export const Route = createFileRoute("/_authed/admin/boundaries")({ component: BoundariesPage, }); @@ -55,6 +79,7 @@ function BoundariesPage() { const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [draft, setDraft] = useState(""); + const [askDraft, setAskDraft] = useState(""); const load = useCallback(async () => { try { @@ -133,6 +158,20 @@ function BoundariesPage() { setDraft(""); }; + /** + * The same rule can sit in both lists, and the deny wins. + * + * Not prevented, because an operator moving a rule from one list to the other will pass through + * that state, and refusing to save it would look like a bug. What it means is stated under the + * list instead, since the gateway decides deny first and an ask alongside it never fires. + */ + const addAskRule = (rule: string) => { + const trimmed = rule.trim(); + if (!trimmed || policy.ask.includes(trimmed)) return; + void save({ ...policy, ask: [...policy.ask, trimmed] }); + setAskDraft(""); + }; + return ( + + {policy.ask.length === 0 ? ( +

+ No rules. Nothing stops to ask. +

+ ) : ( +
    + {policy.ask.map((rule) => ( +
  • + + {rule} + + +
  • + ))} +
+ )} + +
+ { + setAskDraft(event.target.value); + setSaved(false); + }} + onKeyDown={(event) => { + if (event.key === "Enter") addAskRule(askDraft); + }} + placeholder='intent == "write_file" && !matches(file.path, "^notes/")' + value={askDraft} + /> + +
+ +
    + {ASK_PRESETS.map((preset) => ( +
  • + + {preset.cost ? ( + + {preset.cost} + + ) : null} +
  • + ))} +
+ +

+ The Bot stops and waits where one of these matches, and carries on + with the same action if somebody allows it. Checked after the rules + above and before the ones below, so something you have forbidden stays + forbidden and is never offered as a question. In{" "} + Record it and allow it nothing + stops: a match is recorded as a question that would have been asked. +

+
+
    {policy.allow.map((rule) => ( diff --git a/docs/architecture.md b/docs/architecture.md index 9e5f140..9f322b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,7 @@ Policy rules can inspect: - `page.url`, `page.host` - `element.ref`, `element.role`, `element.name`, `element.type` - `key` +- `submit`, true when a type call will press Enter when it has finished - `file.path`, `file.name`, `file.extension` - `mcp.server`, `mcp.tool`, `mcp.effect` - `repeat.count` @@ -67,12 +68,33 @@ the process that served the call, so two API replicas split it, and it covers th workspace only: a call to another server's tools over MCP always reports one. Rules use CEL expressions plus case-insensitive `contains()` and `matches()`. -Deny rules are evaluated before allow rules. The policy engine fails closed: a -missing or empty policy permits nothing, a broken deny rule denies, and a broken -allow rule does not permit. OpenBot's shipped startup default is explicit: -`deny: []` and `allow: ["true"]`, unless `AGENT_COMPUTER_POLICY` or a saved -administrator policy replaces it. A malformed configured policy stops server -startup. +Rules are evaluated in three lists, in order: `deny`, then `ask`, then `allow`. +The policy engine fails closed: a missing or empty policy permits nothing, a +broken deny rule denies, a broken ask rule asks, and a broken allow rule does not +permit. OpenBot's shipped startup default is explicit: `deny: []`, `ask: []` and +`allow: ["true"]`, unless `AGENT_COMPUTER_POLICY` or a saved administrator policy +replaces it. A malformed configured policy stops server startup. + +An `ask` match stops the action and puts it in front of a person in the +conversation, then carries on with the same call if they allow it. The pending +question lives in the server process, is bound to a fingerprint of the exact +action it was raised for, and is single use, so an approval cannot be replayed +against a different one. Answering writes `approval.granted` or `approval.denied` +under the answering person's own actor, separately from the action row, and the +question itself writes `approval.requested` when it is raised. In `dry-run` an +ask is recorded and interrupts nobody. + +The same three lists judge a Bot's MCP tool calls, `ask` included: a rule such as +`intent == "write_tool" && mcp.server == "jira"` stops the call and asks rather +than refusing it, and the question is answered on the same surface, `POST +/api/approvals/:botId/:approvalId`, as one raised by a click. That surface is +mounted whether or not a computer is configured, because a question nobody can be +shown is worse than a rule that never fired. + +Pending questions are held in the server process, like the snapshot cache, so a +deployment running more than one server replica can raise a question on one and +poll for it on another, where it does not exist. Both features assume a single +process today. ## Computers diff --git a/docs/configuration.md b/docs/configuration.md index 0657fb2..ba59015 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -100,7 +100,7 @@ Google OAuth client id and secret must be configured together. If Google OAuth i | `COMPUTER_SUPERVISOR_URL` | Supervisor URL for per-Bot computers. If absent, Bots share `AGENT_COMPUTER_URL`. | | `SUPERVISOR_TOKEN` | Bearer token required by the supervisor. | | `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Local-only private-host browsing when `true`. Cloud metadata addresses are still refused. | -| `AGENT_COMPUTER_POLICY` | JSON action policy: `{"mode":"enforce","deny":[...],"allow":[...]}`. | +| `AGENT_COMPUTER_POLICY` | JSON action policy: `{"mode":"enforce","deny":[...],"ask":[...],"allow":[...]}`. | | `COMPUTER_RUNTIME` | Set to `runsc` to run supervised computers under gVisor. | `agent-computer` also reads: diff --git a/server/drizzle/0001_gigantic_sumo.sql b/server/drizzle/0001_gigantic_sumo.sql new file mode 100644 index 0000000..22458dd --- /dev/null +++ b/server/drizzle/0001_gigantic_sumo.sql @@ -0,0 +1 @@ +ALTER TABLE "action_policy" ADD COLUMN "ask" text[] DEFAULT '{}' NOT NULL; \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..edd784a --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2510 @@ +{ + "id": "c2caefc9-77dd-42f2-9d57-0cb3e87225d0", + "prevId": "084d702b-09fd-44df-a60a-22477be02359", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chunks": { + "name": "chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chunks_document_position_idx": { + "name": "chunks_document_position_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chunks_document_idx": { + "name": "chunks_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_cursors": { + "name": "connector_cursors", + "schema": "", + "columns": { + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_cursors_connector_instance_id_connector_instances_id_fk": { + "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", + "tableFrom": "connector_cursors", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_instances": { + "name": "connector_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "connector_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_instances_credential_id_credentials_id_fk": { + "name": "connector_instances_credential_id_credentials_id_fk", + "tableFrom": "connector_instances", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_acls": { + "name": "document_acls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "acl_effect", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_acls_document_principal_effect_idx": { + "name": "document_acls_document_principal_effect_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_acls_principal_idx": { + "name": "document_acls_principal_idx", + "columns": [ + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_acls_document_id_documents_id_fk": { + "name": "document_acls_document_id_documents_id_fk", + "tableFrom": "document_acls", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_connector_source_idx": { + "name": "documents_connector_source_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_connector_deleted_idx": { + "name": "documents_connector_deleted_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_connector_instance_id_connector_instances_id_fk": { + "name": "documents_connector_instance_id_connector_instances_id_fk", + "tableFrom": "documents", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sync_runs_connector_started_at_idx": { + "name": "sync_runs_connector_started_at_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connector_instance_id_connector_instances_id_fk": { + "name": "sync_runs_connector_instance_id_connector_instances_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_subscriptions": { + "name": "webhook_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": { + "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", + "tableFrom": "webhook_subscriptions", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ask": { + "name": "ask", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acl_effect": { + "name": "acl_effect", + "schema": "public", + "values": ["allow", "deny"] + }, + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.connector_type": { + "name": "connector_type", + "schema": "public", + "values": ["google_drive", "onedrive"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": ["model", "connector", "agent", "mcp"] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": ["pending", "running", "succeeded", "failed"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 540bea2..076f20a 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1786891733850, "tag": "0000_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1787171220703, + "tag": "0001_gigantic_sumo", + "breakpoints": true } ] } diff --git a/server/src/app.ts b/server/src/app.ts index 7675d47..945f5ed 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,6 +19,8 @@ import { createComponentRoutes } from "./components/routes"; import type { SandboxedStore } from "./components/sandboxed"; import { createSandboxedRoutes } from "./components/sandboxed-routes"; import type { ComponentStore } from "./components/store"; +import { createApprovalRoutes } from "./computer/approval-routes"; +import type { ApprovalRegistry } from "./computer/approvals"; import type { ComputerClient } from "./computer/client"; import type { ComputerGateway } from "./computer/gateway"; import type { PolicyStore } from "./computer/policy-store"; @@ -95,6 +97,15 @@ export function createApp( * says nothing about which deployment the conversation belongs to. */ threadIdentity?: ThreadIdentity, + /** + * Where the questions an `ask` rule raised wait for a person. + * + * Deliberately not tied to the computer being configured. The same policy judges a Bot's calls to + * somebody else's servers, so a deployment with plugins and no browser can still stop and ask, and + * a question nobody can be shown is worse than a rule that never fired: the Bot waits out the full + * ten minutes and then reports that nobody answered. + */ + approvals?: ApprovalRegistry, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -310,6 +321,14 @@ export function createApp( ); } + // Answering is its own surface because asking is not only the computer's. See approval-routes.ts. + if (approvals && auditStore) { + app.route( + "/api/approvals", + createApprovalRoutes(approvals, auditStore, requireUser), + ); + } + if (agentProfileStore) { app.route( "/api/agents", diff --git a/server/src/audit.ts b/server/src/audit.ts index 3a826e7..69fe18c 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -90,6 +90,29 @@ export const auditEventTypes = [ // which field it went in; the value is on a path this trail is not on. "computer.secret_requested", "computer.secret_supplied", + /** + * The boundary stopping to ask a person, and what they said. + * + * Three rows rather than a flag on the action, because the three facts are separable and the gaps + * between them are where the interesting failures live. A question that was asked and never + * answered leaves only the first row, and that is the shape of "the Bot sat waiting while nobody + * was watching the screen", which nothing else in this trail would show. An answer that was given + * and never spent leaves the first two, which is what a stopped run looks like from here. + * + * `approval.granted` and `approval.denied` are written by the person answering, under their own + * actor, minutes after the Bot's turn raised the question and often by somebody else entirely. + * Folding consent into the action row would credit it to whoever was driving the Bot, which is the + * one thing an approval trail must never do. + * + * Not named for the computer, unlike the rows above, because the same boundary judges a Bot's + * calls to somebody else's servers and stops to ask about those too. Each row is filed against the + * thing the question was about, a computer or a tool, so a reader filtering by either sees the + * question, the answer and the action together rather than a grant filed under a browser for + * something that happened in Jira. + */ + "approval.requested", + "approval.granted", + "approval.denied", // The computer itself being stopped or wiped. `reset` destroys every login the Bot had, which is // both the recovery path and the most consequential button on the admin page, so who pressed it and // when is exactly the sort of thing an investigator needs and nothing else records. diff --git a/server/src/computer/approval-routes.ts b/server/src/computer/approval-routes.ts new file mode 100644 index 0000000..a4c0939 --- /dev/null +++ b/server/src/computer/approval-routes.ts @@ -0,0 +1,127 @@ +/** + * Where a person answers the questions a boundary raised, wherever it raised them. + * + * Its own surface rather than a pair of handlers under the computer, because the computer is not the + * only thing the action policy judges. The same rules decide a Bot's calls to somebody else's + * servers, an `ask` rule written about those is the shape operators reach for first, "ask me before + * anything changes anything in Jira", and a deployment that runs plugins without a browser would + * otherwise raise questions on a surface it never mounted: the Bot would sit for the full ten + * minutes and then report that nobody answered, having never asked anybody. + * + * Answering is a person acting, so it is audited as one, under their own actor and against the thing + * the question was about rather than against whatever endpoint they happened to press the button on. + * The row is written here, next to the answer, because these two handlers are the only place in the + * product where consent is recorded and a second place would eventually record it differently. + */ +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import { type AuditStore, recordAuditEvent } from "../audit"; +import type { AppVariables } from "../auth/guards"; +import { + type ApprovalRegistry, + type PendingApproval, + presentable, +} from "./approvals"; + +/** + * The local actor's address, matched to decide whether the id is a real users row. + * + * Compared against rather than imported from `auth/dev-actor` because this must not depend on the + * authentication module's internals; this is the one fact about it that matters here. + */ +const DEV_ACTOR_EMAIL = "dev@openbot.local"; + +export function createApprovalRoutes( + approvals: ApprovalRegistry, + auditStore: AuditStore, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + + /** + * The questions this Bot is waiting on, for the surface to poll. + * + * A read, so no audit row, exactly like asking who holds the wheel. The interesting rows are the + * one written when the question was raised and the one written when somebody answered it. + */ + routes.get("/:botId", requireUser, (context) => + context.json({ + approvals: approvals + .pending(context.req.param("botId") ?? "") + .map(presentable), + }), + ); + + routes.post("/:botId/:approvalId", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as Record< + string, + unknown + > | null; + // Said explicitly, never defaulted. A body that forgot to say which way it went must not be + // read as an approval, and reading a missing field as a refusal would be equally wrong. + if (typeof body?.granted !== "boolean") { + return context.json( + { error: "Say whether this is allowed or not." }, + 400, + ); + } + + const botId = context.req.param("botId") ?? ""; + const record = context.var.actor; + const answered = approvals.answer( + context.req.param("approvalId") ?? "", + botId, + record.id, + body.granted, + ); + // Nothing is broken and there is nothing to fix: the question expired, or somebody else answered + // it, most likely in another tab. A conflict rather than a fault. + if (!answered.ok) { + return context.json( + { + error: + "That request is no longer waiting for an answer. It may have expired, or somebody else answered it.", + }, + 409, + ); + } + + await recordAuditEvent(auditStore, { + eventType: body.granted ? "approval.granted" : "approval.denied", + targetType: answered.approval.target.type, + targetId: answered.approval.target.id, + // Only a real users row may go in the audit table's foreign key column. The local development + // actor is not one, so writing it there fails the constraint and loses the row entirely. Who + // it was is recorded in the payload regardless. + ...(record.email === DEV_ACTOR_EMAIL ? {} : { actorUserId: record.id }), + payload: payloadFor(answered.approval, record.id), + }); + + // Projected, like the list above. What the surface does with an answer is stop showing the + // question, and nothing it needs for that is worth sending the binding out of this process for. + return context.json(presentable(answered.approval)); + }); + + return routes; +} + +/** + * What an answer records: the question, the boundary that raised it, and who answered. + * + * The Bot whose turn met the rule comes off the approval rather than out of the request, so the row + * says which Bot it was actually about even if somebody arrives at the wrong address with a real id. + */ +function payloadFor(approval: PendingApproval, answeredBy: string) { + return { + bot: approval.botId, + actor: answeredBy, + approval: approval.id, + rule: approval.rule, + // The question as a person read it, so the trail records what they were shown rather than a + // reconstruction of it. + reason: approval.question, + // Who was driving when the boundary stopped, which is usually not who answered. The gap between + // the two is the reason this is its own row. + asked: approval.actor, + }; +} diff --git a/server/src/computer/approvals.ts b/server/src/computer/approvals.ts new file mode 100644 index 0000000..a805a20 --- /dev/null +++ b/server/src/computer/approvals.ts @@ -0,0 +1,332 @@ +/** + * The questions a Bot is waiting on a person to answer, and the binding that makes an answer mean + * exactly one thing. + * + * In memory, per process, and deliberately not in the database. A pending question is about a live + * browser session and a live turn: the snapshot the refs came from, the page that is open, the model + * that is mid-run holding the tool call. A restart takes all three with it, so a persisted approval + * would come back as a grant for an action nobody could still perform, attached to a conversation + * nobody is having. Worse, it would be a grant nobody remembers giving. The safe reading of a + * restart is that every open question was withdrawn, and the safe way to guarantee that is to keep + * the questions somewhere a restart empties. + * + * The important part is the fingerprint. An approval registry without one is a dialog box: a person + * presses Allow, an id comes back, and the model may then spend that id on any action it likes. + * Binding an approval to a hash of the action it was granted for is what stops "yes, click Place + * order" being replayable as "yes, click Delete account", and it is the reason this is a governance + * feature rather than a confirmation prompt. + * + * It lives beside the computer because that is where the boundary it serves was written, and it is + * not only the computer's, the same policy judges a Bot's calls to somebody else's servers and those + * raise the same questions. One registry per deployment rather than one per subsystem: a Bot waiting + * on a person is waiting on a person, and a deployment with two of these would be a deployment where + * the surface a person answers on decides which half of a Bot's work they can see. + */ +import { createHash, randomUUID } from "node:crypto"; + +/** + * How long a question stays open. + * + * Ten minutes, the same window the surface gives a person to answer a request for help or a secret. + * Long enough that somebody who walked away from the screen can still come back and decide; finite + * because a run holding a tool call open forever is a hung Bot, and because an approval that outlives + * anybody's memory of the question is not consent. + */ +export const APPROVAL_TTL_MS = 10 * 60_000; + +/** + * The action an approval is about, in the fields a fingerprint is taken over. + * + * Everything here is known to the caller before it acts and is derived from the request it is + * actually about to make, never from anything the model asserted about it. + */ +export type ApprovalSubject = { + botId: string; + toolName: string; + ref?: string | undefined; + key?: string | undefined; + /** True when a `computer_type` call will press Enter afterwards. See PolicyContext.submit. */ + submit?: boolean | undefined; + filePath?: string | undefined; + pageUrl?: string | undefined; + /** + * The arguments a tool call carries, for the calls whose whole meaning is in them. + * + * A browser action is identified by the thing it touches: a ref, a key, a path. A call to somebody + * else's server is not. `postMessage` on the same Bot's same server is one action when it says + * "the deploy finished" in a team channel and another when it says something else to a customer, + * and a fingerprint that stopped at the tool name would make one person's yes cover both. + * + * Left out where the arguments are not the identity, so that a person who allowed a click is not + * asked again because a snapshot id moved on. + */ + arguments?: Record | undefined; +}; + +export type PendingApproval = { + id: string; + botId: string; + /** Who was driving the Bot when it met the rule. Not necessarily who answers. */ + actor: string; + /** The expression that asked, so the surface and the trail can name the boundary. */ + rule: string; + /** What is about to happen, in one sentence, as the policy phrased it. */ + question: string; + /** + * What the question is about, in the terms the audit trail files things under. + * + * Carried on the approval because the person answering arrives minutes later on a surface that + * knows nothing but an id, and the row their answer writes has to land against the same thing the + * action's own row will. Without it every answer would be filed against whichever subsystem + * happened to own the endpoint they pressed the button on, so a yes to a tool call on somebody + * else's server would be recorded as something that happened on a browser. + */ + target: { type: string; id: string }; + /** + * The action this approval is good for, and only this one. + * + * Kept on the record rather than recomputed at consumption time from whatever arrives, because the + * whole point is to compare the action a person saw against the action being attempted. + */ + fingerprint: string; + requestedAt: string; + expiresAt: string; + /** Undefined until somebody answers. False is an answer, and a final one. */ + granted?: boolean; + /** Who answered, recorded so the audit row credits the decision to a person rather than to a Bot. */ + answeredBy?: string; +}; + +/** + * An approval as a surface is allowed to see it. + * + * The fingerprint is the binding between an approval and its action, the actor is the person the + * turn belonged to, and the target is bookkeeping for the trail. None of the three is any use to a + * browser and all three are compared or written on the server, so they do not travel. + * + * One projection rather than one per handler. The reading endpoint and the answering endpoint sit + * four lines apart and return the same record, and the way that goes wrong is that somebody adds a + * field to the record and only one of them keeps it out; a stated invariant of a security surface + * being quietly broken by a sibling handler is a worse failure than the field itself. + */ +export type PresentedApproval = { + id: string; + botId: string; + rule: string; + question: string; + requestedAt: string; + expiresAt: string; + granted?: boolean; + answeredBy?: string; +}; + +export function presentable(approval: PendingApproval): PresentedApproval { + return { + id: approval.id, + botId: approval.botId, + rule: approval.rule, + question: approval.question, + requestedAt: approval.requestedAt, + expiresAt: approval.expiresAt, + ...(approval.granted === undefined ? {} : { granted: approval.granted }), + ...(approval.answeredBy ? { answeredBy: approval.answeredBy } : {}), + }; +} + +export type ApprovalAnswer = + | { ok: true; approval: PendingApproval } + /** One reason, because a person acts on all three identically: that question is no longer open. */ + | { ok: false; reason: "no longer open" }; + +export type ApprovalConsumption = + | { ok: true; approval: PendingApproval } + | { + ok: false; + reason: "unknown" | "unanswered" | "declined" | "a different action"; + }; + +/** + * A stable hash of the action, used to bind one approval to one thing. + * + * Hashed rather than stored as a tuple so the value is a single opaque string that can be compared in + * one line and cannot be partially matched by accident. The parts are joined with a NUL, which no + * field can contain, so that a ref of "a" with a key of "bc" cannot produce the same fingerprint as + * "ab" with "c". + * + * The Bot's id is in here first, which is what stops an approval granted on one Bot's computer being + * spent on another's: two Bots doing the identical thing produce two different fingerprints, and + * neither can consume the other's. + */ +export function fingerprintOf(subject: ApprovalSubject): string { + return createHash("sha256") + .update( + [ + subject.botId, + subject.toolName, + subject.ref ?? "", + subject.key ?? "", + subject.submit === true ? "submit" : "", + subject.filePath ?? "", + subject.pageUrl ?? "", + subject.arguments ? canonical(subject.arguments) : "", + ].join("\u0000"), + ) + .digest("hex"); +} + +/** + * JSON with its keys in a fixed order, so two spellings of the same arguments hash the same. + * + * `JSON.stringify` keeps whatever order an object was built in, and the same call arrives here + * twice: once when the question is asked and once when the answer is spent, with a parse in between. + * Sorting makes the comparison about what the arguments say rather than about the order somebody's + * client happened to write them in, which is not a difference anybody would understand being asked + * about twice. + */ +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) + .join(",")}}`; + } + // `undefined` stringifies to nothing at all, which would let a field somebody sent as undefined + // hash the same as one they never sent. + return JSON.stringify(value) ?? "null"; +} + +export type ApprovalRegistry = { + /** Open a question. The id it returns is what a caller presents once somebody has answered. */ + request: (input: { + botId: string; + actor: string; + rule: string; + question: string; + fingerprint: string; + target: { type: string; id: string }; + }) => PendingApproval; + /** + * The open questions for one Bot, newest last, expired ones already gone. + * + * Includes answered-but-unspent ones, because the waiting caller learns the answer by finding its + * own id in this list. The surface shows only the unanswered ones. + */ + pending: (botId: string) => PendingApproval[]; + /** + * Answer one question, on the Bot it was asked about. + * + * The Bot is named rather than looked up from the id, and it has to match. The id is enough to + * find the entry, so this is not authorisation, it is bookkeeping that cannot be wrong: the + * surface takes the Bot from the address it was called on, and the row an answer writes says which + * Bot it was about. Without the check those two can disagree, and then the trail holds a grant + * filed under one Bot and the action it paid for filed under another, joined by an id that appears + * on both and reconciles neither. "Who approved what" is the one question this record exists to + * answer. + */ + answer: ( + id: string, + botId: string, + actor: string, + granted: boolean, + ) => ApprovalAnswer; + /** Spend an approval on one action. Single use: a successful consumption removes it. */ + consume: (id: string, fingerprint: string) => ApprovalConsumption; +}; + +export function createApprovalRegistry( + options: { + /** Injectable so expiry can be tested without a test that sleeps for ten minutes. */ + now?: () => number; + ttlMs?: number; + } = {}, +): ApprovalRegistry { + const now = options.now ?? (() => Date.now()); + const ttlMs = options.ttlMs ?? APPROVAL_TTL_MS; + const open = new Map(); + + /** + * Drop what has run out, on every read. + * + * On read rather than on a timer, because a timer keeps a process alive and adds a thing that can + * be forgotten in a test; nothing here matters until somebody looks, and everything that looks + * sweeps first. + */ + const sweep = () => { + const at = now(); + for (const [id, approval] of open) { + if (Date.parse(approval.expiresAt) <= at) open.delete(id); + } + }; + + return { + request: (input) => { + sweep(); + const at = now(); + const approval: PendingApproval = { + id: randomUUID(), + botId: input.botId, + actor: input.actor, + rule: input.rule, + question: input.question, + fingerprint: input.fingerprint, + target: input.target, + requestedAt: new Date(at).toISOString(), + expiresAt: new Date(at + ttlMs).toISOString(), + }; + open.set(approval.id, approval); + return approval; + }, + + pending: (botId) => { + sweep(); + return [...open.values()].filter((approval) => approval.botId === botId); + }, + + answer: (id, botId, actor, granted) => { + sweep(); + const approval = open.get(id); + // An answered question is not answerable again, whichever way it went. Otherwise a second + // person, or the same person in a second tab, can quietly overturn a decision that the trail + // has already recorded as made. + // + // A question asked about another Bot is reported the same way, because from where the caller + // is standing it is the same fact: nothing is open here under that id. + if ( + !approval || + approval.botId !== botId || + approval.granted !== undefined + ) { + return { ok: false, reason: "no longer open" }; + } + const answered: PendingApproval = { + ...approval, + granted, + answeredBy: actor, + }; + open.set(id, answered); + return { ok: true, approval: answered }; + }, + + consume: (id, fingerprint) => { + sweep(); + const approval = open.get(id); + if (!approval) return { ok: false, reason: "unknown" }; + if (approval.granted === undefined) { + return { ok: false, reason: "unanswered" }; + } + if (approval.granted === false) return { ok: false, reason: "declined" }; + if (approval.fingerprint !== fingerprint) { + // Left in place rather than burned. A mismatch is the replay this whole mechanism exists to + // stop, and destroying the approval on the way past would let a model that guessed wrong take + // the person's grant away from the action they actually meant it for. + return { ok: false, reason: "a different action" }; + } + // Single use. A grant is permission for one thing to happen once; leaving it spendable would + // make "yes" mean "yes, as often as you like", which is not what anybody pressing Allow on one + // button thinks they are agreeing to. + open.delete(id); + return { ok: true, approval }; + }, + }; +} diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index c435d78..bbe3b71 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -18,6 +18,12 @@ * The refs are opaque to the caller precisely so that the server holds the mapping. */ import { type AuditStore, recordAuditEvent } from "../audit"; +import { + type ApprovalRegistry, + createApprovalRegistry, + fingerprintOf, + type PendingApproval, +} from "./approvals"; import type { ComputerClient } from "./client"; import { type ActionPolicy, @@ -51,6 +57,32 @@ export class ActionRefusedError extends Error { } } +/** + * The boundary wants a person's answer before this happens. + * + * Emphatically not an {@link ActionRefusedError}. A refusal is final and the Bot should say so and + * move on; this one is a pause, and the same Bot presenting the same request again with an approval + * on it is the intended next step rather than an attempt to get around anything. Collapsing the two + * would teach a model to give up on exactly the actions a deployment was willing to permit, which is + * the failure that makes an ask list worse than useless. + */ +export class ActionNeedsApprovalError extends Error { + /** What the caller presents once somebody has answered. */ + readonly approvalId: string; + /** The question in the words a person is being shown, so the Bot can say what it is waiting for. */ + readonly question: string; + /** The rule that asked, so the surface can name the boundary the way a refusal does. */ + readonly rule: string; + + constructor(approval: PendingApproval) { + super(approval.question); + this.name = "ActionNeedsApprovalError"; + this.approvalId = approval.id; + this.question = approval.question; + this.rule = approval.rule; + } +} + /** Who is asking. The gateway records this; it does not decide it. */ export type ActionActor = { /** The signed-in person, or the local actor when authentication is not configured. */ @@ -77,6 +109,16 @@ export type ComputerGatewayOptions = { auditStore: AuditStore; /** Absent denies everything. See evaluateActionPolicy. */ policy: () => ActionPolicy | undefined; + /** + * Where questions raised by the `ask` list wait for an answer. + * + * Handed in rather than owned, because the deployment has exactly one of these and the gateway is + * not the only thing that asks: the same policy judges a Bot's calls to somebody else's servers, + * and a person answering should see everything their Bot is waiting on rather than whichever half + * belongs to the subsystem that happened to serve the page. A gateway built without one keeps its + * own, which is what the tests do so they can control the clock. + */ + approvals?: ApprovalRegistry; /** * Counts a Bot repeating itself, so that the policy can be told how many times. * @@ -105,6 +147,7 @@ type CachedSnapshot = { export function createComputerGateway(options: ComputerGatewayOptions) { const { client, auditStore, supervisor } = options; const snapshots = new Map(); + const approvals = options.approvals ?? createApprovalRegistry(); const repeat = options.repeat ?? createRepeatDetector(); /** @@ -165,8 +208,19 @@ export function createComputerGateway(options: ComputerGatewayOptions) { filePath?: string; targetUrl?: string; key?: string; + /** Whether this call ends by pressing Enter. Only the type tool can, and it says so. */ + submit?: boolean; /** The person's Stop, on its way to the browser. See the acting methods below. */ signal?: AbortSignal; + /** + * An answer a person already gave, being presented for the action it was given for. + * + * Carried on the request rather than held against the conversation, because the thing being + * checked is not "has somebody approved something recently" but "was this exact action the one + * they were shown". The id alone proves nothing; it is the id plus the fingerprint of the call + * being made that means anything. + */ + approvalId?: string; }, run: () => Promise, ): Promise { @@ -203,6 +257,9 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: { id: actor.id }, page: { url: pageUrl, host: hostOf(pageUrl) }, repeat: { count: repetition.count }, + // Always a boolean, unlike `key`, so a rule about form submission needs no guard to stay + // evaluable on the actions that cannot submit anything. See PolicyContext.submit. + submit: subject.submit === true, ...(intent ? { intent } : {}), ...(subject.key ? { key: subject.key } : {}), ...(element @@ -257,6 +314,81 @@ export function createComputerGateway(options: ComputerGatewayOptions) { } const decision = evaluateActionPolicy(options.policy(), context); + + /** + * A decision that wants a person, resolved before anything is recorded as having happened. + * + * Two outcomes and no third: either an approval already exists for this exact action, in which + * case the row below says so and names who gave it, or the question is opened and the call stops + * here. Nothing is written as allowed or refused in the second case, because neither happened: + * `approval.requested` is the record of where the turn actually got to. + * + * Dry-run never reaches this branch, because the policy forwards an ask there for the same + * reason it forwards a deny: a mode that promises to change nothing must not start interrupting + * people. + */ + let approvedBy: string | undefined; + if (decision.source === "ask" && !decision.forward) { + const fingerprint = fingerprintOf({ + botId, + toolName, + ref, + key: subject.key, + submit: subject.submit, + filePath, + pageUrl, + }); + const presented = subject.approvalId + ? approvals.consume(subject.approvalId, fingerprint) + : undefined; + + if (presented?.ok && presented.approval.answeredBy) { + approvedBy = presented.approval.answeredBy; + } else { + // Every unsuccessful presentation asks again rather than failing: an expired approval, an id + // spent already, a person's No being replayed, and an approval granted for a different button + // all mean the same thing here, which is that nobody has agreed to THIS. Asking twice is + // annoying and safe; guessing which of those deserves an error is neither. + // + // An approval with nobody's name on it lands here too. Nothing can produce one, because an + // answer always records who gave it and an unanswered approval cannot be spent, and it asks + // again rather than falling back to the person whose turn raised the question: crediting + // consent to whoever was driving the Bot is the one thing this record must never do. + const pending = approvals.request({ + botId, + actor: actor.id, + rule: decision.matched ?? "", + question: decision.reason, + fingerprint, + // Where the answer's own row will be filed, decided here where what the question is about + // is still known. See PendingApproval.target. + target: { type: "computer", id: computerId }, + }); + await writeApprovalEvent(auditStore, { + botId, + actor, + computerId, + approval: pending, + toolName, + pageUrl, + filePath, + }); + throw new ActionNeedsApprovalError(pending); + } + } + + // What the boundary settled on, once a person's answer is folded in. The source stays `ask`, so + // the row reads as "allowed, because somebody was asked and said yes" rather than as an ordinary + // permission nobody ever questioned. + const settled: PolicyDecision = approvedBy + ? { + ...decision, + allowed: true, + forward: true, + reason: `Allowed by ${approvedBy}, who was asked because of the rule \`${decision.matched}\`.`, + } + : decision; + await write(auditStore, { toolName, botId, @@ -267,11 +399,12 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ...(subject.key ? { key: subject.key } : {}), filePath, pageUrl, - decision, + decision: settled, + ...(approvedBy ? { approvedBy } : {}), }); - if (!decision.forward) { - throw new ActionRefusedError(decision.reason, decision.matched); + if (!settled.forward) { + throw new ActionRefusedError(settled.reason, settled.matched); } let result: T; @@ -297,7 +430,8 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ref, filePath, pageUrl, - decision, + decision: settled, + ...(approvedBy ? { approvedBy } : {}), failure: error instanceof Error ? error.message : "The action failed.", }); throw error; @@ -515,13 +649,21 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, url: string, + /** + * An answer a person gave to this exact call, if one has been given. + * + * Last and optional on every acting method, so a caller that knows nothing about approvals + * behaves exactly as it did and a route that forgets to pass it fails by asking again rather + * than by acting unasked. + */ + approvalId?: string, ) { return govern( computerId, "computer_navigate", botId, actor, - { targetUrl: url }, + { targetUrl: url, ...(approvalId ? { approvalId } : {}) }, () => as(botId).navigate(url), ); }, @@ -532,13 +674,18 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: ClickInput, signal?: AbortSignal, + approvalId?: string, ) { return govern( computerId, "computer_click", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + ...(signal ? { signal } : {}), + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).click(input, signal), ); }, @@ -549,13 +696,22 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: TypeInput, signal?: AbortSignal, + approvalId?: string, ) { return govern( computerId, "computer_type", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + // Whether this call ends by pressing Enter, which is the third way into a form and the one + // a rule about clicking and a rule about `key` both miss. The computer presses it itself, + // so it never arrives here as an action of its own to be judged. + submit: input.submit === true, + ...(signal ? { signal } : {}), + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).type(input, signal), ); }, @@ -566,6 +722,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: KeyInput, signal?: AbortSignal, + approvalId?: string, ) { return govern( computerId, @@ -574,7 +731,12 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor, // The key is part of the subject, so a rule can tell Enter from a letter. Form submission can // happen through a keypress as well as a click, so the policy context carries the key. - { ref: input.ref, key: input.key, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + key: input.key, + ...(signal ? { signal } : {}), + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).key(input, signal), ); }, @@ -584,9 +746,15 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ScrollInput, + approvalId?: string, ) { - return govern(computerId, "computer_scroll", botId, actor, {}, () => - as(botId).scroll(input), + return govern( + computerId, + "computer_scroll", + botId, + actor, + { ...(approvalId ? { approvalId } : {}) }, + () => as(botId).scroll(input), ); }, @@ -602,13 +770,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ReadFileInput, + approvalId?: string, ) { return govern( computerId, "computer_read_file", botId, actor, - { filePath: input.path }, + { filePath: input.path, ...(approvalId ? { approvalId } : {}) }, () => as(botId).readFile(input), ); }, @@ -623,13 +792,17 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ListFilesInput, + approvalId?: string, ) { return govern( computerId, "computer_list_files", botId, actor, - { filePath: input.path ?? "." }, + { + filePath: input.path ?? ".", + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).listFiles(input), ); }, @@ -639,13 +812,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: WriteFileInput, + approvalId?: string, ) { return govern( computerId, "computer_write_file", botId, actor, - { filePath: input.path }, + { filePath: input.path, ...(approvalId ? { approvalId } : {}) }, () => as(botId).writeFile(input), ); }, @@ -740,6 +914,14 @@ async function write( filePath: string | undefined; pageUrl: string; decision: PolicyDecision; + /** + * Who allowed this, when the boundary asked and somebody said yes. + * + * On the action row as well as on the approval row, because the two are found by different + * questions: a reader following one Bot's actions should not have to go and correlate ids to + * discover that a person stood behind this particular click. + */ + approvedBy?: string; /** Set only when a permitted action was attempted and did not succeed. */ failure?: string; }, @@ -792,6 +974,7 @@ async function write( mode: entry.decision.mode, source: entry.decision.source, rule: entry.decision.matched, + ...(entry.approvedBy ? { approvedBy: entry.approvedBy } : {}), /** Present so the trail explains a dry-run row that was recorded as refused but still ran. */ carriedOut: entry.decision.forward, }, @@ -889,6 +1072,55 @@ async function writeControlEvent( }); } +/** + * The row for a question the boundary stopped to ask. + * + * Its own writer rather than a variant of either of the others, because an approval sits between + * them and fits neither shape. It is not `write`: no decision was reached, the policy said it wanted + * a person and the turn stopped there, and inventing an allowed-or-refused verdict for that row would + * be the comfortable fiction the rest of this file is careful to avoid. It is not + * `writeControlEvent`: a handover is a person taking the browser away from the Bot, whereas this is a + * question about one specific action, so the row has to name the action or a reader cannot tell what + * was being agreed to. + * + * The answer's row is written where the answer is given, which is not here. All of them carry the + * approval id: that is what lets a reader join a request to its answer and to the action that + * finally happened, and it is the only way to see the case that matters most, a question that was + * asked and never answered. + */ +async function writeApprovalEvent( + auditStore: AuditStore, + entry: { + botId: string; + actor: ActionActor; + computerId: string; + approval: PendingApproval; + toolName?: string; + pageUrl?: string; + filePath?: string | undefined; + }, +) { + await recordAuditEvent(auditStore, { + eventType: "approval.requested", + targetType: "computer", + targetId: entry.computerId, + ...(entry.actor.userId ? { actorUserId: entry.actor.userId } : {}), + payload: { + bot: entry.botId, + actor: entry.actor.id, + approval: entry.approval.id, + rule: entry.approval.rule, + // The question as a person read it. Element labels are things a page displays rather than + // things anybody typed, which is why the reason text is safe to keep here; see the note on + // `write` above. + reason: entry.approval.question, + ...(entry.toolName ? { action: entry.toolName } : {}), + ...(entry.pageUrl ? { page: entry.pageUrl } : {}), + ...(entry.filePath ? { file: entry.filePath } : {}), + }, + }); +} + function hostOf(url: string): string { try { return new URL(url).host; diff --git a/server/src/computer/policy-store.ts b/server/src/computer/policy-store.ts index 053ddd1..216f411 100644 --- a/server/src/computer/policy-store.ts +++ b/server/src/computer/policy-store.ts @@ -36,6 +36,7 @@ const CURRENT = "current"; export const DEFAULT_ACTION_POLICY: ActionPolicy = { mode: "enforce", deny: [], + ask: [], allow: ["true"], }; @@ -73,6 +74,7 @@ export function createPolicyStore( id: CURRENT, mode: next.mode, deny: next.deny, + ask: next.ask, allow: next.allow, updatedBy: by ?? null, updatedAt: new Date(), @@ -82,6 +84,7 @@ export function createPolicyStore( set: { mode: next.mode, deny: next.deny, + ask: next.ask, allow: next.allow, updatedBy: by ?? null, updatedAt: new Date(), @@ -113,6 +116,7 @@ export function createPolicyStore( current = { mode: row.mode as ActionPolicy["mode"], deny: [...row.deny], + ask: [...row.ask], allow: [...row.allow], }; return "the database"; @@ -124,6 +128,7 @@ function clone(policy: ActionPolicy): ActionPolicy { return { mode: policy.mode, deny: [...policy.deny], + ask: [...policy.ask], allow: [...policy.allow], }; } @@ -155,8 +160,12 @@ export function parseActionPolicy( }; } - const lists: Record<"deny" | "allow", string[]> = { deny: [], allow: [] }; - for (const key of ["deny", "allow"] as const) { + const lists: Record<"deny" | "ask" | "allow", string[]> = { + deny: [], + ask: [], + allow: [], + }; + for (const key of ["deny", "ask", "allow"] as const) { const value = candidate[key] ?? []; if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) { return { ok: false, error: `${key} must be a list of expressions.` }; @@ -164,5 +173,11 @@ export function parseActionPolicy( lists[key] = value as string[]; } - return { ok: true, policy: { mode, deny: lists.deny, allow: lists.allow } }; + // `ask` defaults to empty like the others, so a policy written before this list existed still + // parses and still means what it meant. A deployment that has never asked anybody anything is a + // deployment with no ask rules, not an invalid one. + return { + ok: true, + policy: { mode, deny: lists.deny, ask: lists.ask, allow: lists.allow }, + }; } diff --git a/server/src/computer/policy.ts b/server/src/computer/policy.ts index 06ac3a9..b453683 100644 --- a/server/src/computer/policy.ts +++ b/server/src/computer/policy.ts @@ -11,8 +11,15 @@ * thought of; an expression language can express the one they thought of. This is also the language * the enterprise gateway already speaks, so a rule written here means the same thing there. * - * Precedence: deny beats allow. A rule that removes permission must never be + * Precedence: deny, then ask, then allow. A rule that removes permission must never be * defeated by a broader rule that grants it, or a company cannot reason about what it has forbidden. + * + * `ask` sits between the two, and both halves of that position are deliberate. It must not soften a + * `deny`, because a thing a deployment has forbidden is not up for renegotiation at a prompt, and an + * approval box in front of a tired person at the end of a task is not the review anybody signed off + * when they wrote the deny rule. And it must beat `allow`, because the policy this product ships with + * is `allow: ["true"]`: an ask evaluated after the allow list would be unreachable in the default + * configuration, so the first rule anybody ever wrote here would silently do nothing. */ import { evaluate } from "cel-js"; @@ -27,8 +34,18 @@ export type ActionPolicy = { * governance feature. */ mode: PolicyMode; - /** Evaluated first. Any expression true means refused, whatever `allow` says. */ + /** Evaluated first. Any expression true means refused, whatever `ask` or `allow` says. */ deny: string[]; + /** + * Any expression true means a person is asked before the action runs. + * + * The middle answer a boundary with two lists cannot give. "It may spend money, but not more than + * fifty pounds without me" and "it may do this, but I want to see the first one" are the shapes + * every deployment reaches for once it trusts a Bot enough to let it act at all, and until this + * list existed the only way to express either was to forbid the action and have a person take the + * wheel, which throws away the Bot's turn and everything it had worked out to get there. + */ + ask: string[]; /** Any expression true means permitted. Empty means nothing is permitted. */ allow: string[]; }; @@ -93,6 +110,22 @@ export type PolicyContext = { * to block both routes, which the deny example in `.env.example` now does. */ key?: string; + /** + * Whether a `computer_type` call will press Enter when it has finished typing. + * + * The third door into a form, and the one a rule about clicking and a rule about `key` both miss. + * The type tool takes a flag meaning "and submit", the computer presses Enter itself, and no + * keypress ever reaches the gateway as its own action, so a boundary written against Enter watched + * the Bot walk past it: a preset named "never submit a form" left the one call that submits a + * single-field form untouched. + * + * Present on every action rather than only on the calls that can set it, and false is doing work + * there. `key` is absent unless a key was pressed, which is why the presets that mention it have + * to be guarded by tool name to stay evaluable; a rule saying `submit` should not need that + * ceremony, because the whole point of the field is that an operator writes one short sentence + * about form submission and it holds everywhere. + */ + submit?: boolean; /** * What the action does, rather than which tool was called. * @@ -110,7 +143,8 @@ export type PolicyContext = { * from Enter in any field of it, and the element a keypress names is the field, not the form. The * gateway would need to know the page's structure at decision time, which it does not, refs are * held off-DOM by Playwright and the policy runs before the action reaches the browser. So a rule - * that must stop a submission still has to refuse Enter outright, and the preset says so. + * that must stop a submission still has to refuse Enter outright, and the preset says so. `submit` + * above covers the one case where the intention is not a guess, because the Bot asked for it. */ intent?: | "activate" @@ -166,8 +200,15 @@ export type PolicyDecision = { mode: PolicyMode; /** Which expression decided it, so the audit row can say why and an operator can find the rule. */ matched: string | null; - /** Which list that expression came from. `default` means nothing matched and the floor applied. */ - source: "deny" | "allow" | "default"; + /** + * Which list that expression came from. `default` means nothing matched and the floor applied. + * + * `ask` is not a verdict on its own: it says the boundary wants a person's answer, and the caller + * decides what to do about that. The gateway either finds an approval already granted for this + * exact action or stops and asks; both outcomes are recorded with this source, so the trail can + * tell an action a person consented to from one nothing ever questioned. + */ + source: "deny" | "ask" | "allow" | "default"; /** True when the action should actually be carried out. False for a refusal in `enforce`. */ forward: boolean; /** Why, in words that go in front of a person. */ @@ -247,6 +288,7 @@ export function evaluateActionPolicy( ): PolicyDecision { const mode: PolicyMode = policy?.mode ?? "enforce"; const deny = policy?.deny ?? []; + const ask = policy?.ask ?? []; const allow = policy?.allow ?? []; // Deny first, and a broken deny expression still denies. One typo in a rule therefore blocks the @@ -267,6 +309,27 @@ export function evaluateActionPolicy( } } + // Ask second, and a broken ask expression asks. The same reasoning as the deny loop, with a gentler + // cost: a typo here interrupts somebody who was not expecting to be interrupted, which is a + // nuisance, whereas the alternative is a rule that quietly permits exactly the thing it was written + // to hold back. A boundary whose failures land on the permissive side is not a boundary. + for (const expression of ask) { + if (matches(expression, context, true)) { + return { + allowed: false, + mode, + matched: expression, + source: "ask", + // Nothing happens until somebody says so, except in dry-run, where the whole promise is that + // switching the policy on changes nothing. A dry-run ask is a note in the trail saying "here + // is where you would have been interrupted", which is precisely what an operator trying a + // rule out against real traffic wants to find out before it starts stopping anybody. + forward: mode === "dry-run", + reason: describeAsk(context), + }; + } + } + for (const expression of allow) { if (matches(expression, context, false)) { return { @@ -292,11 +355,47 @@ export function evaluateActionPolicy( }; } +/** + * The verb a person reads, chosen from what the action does rather than which tool ran. + * + * A question phrased as "allow computer_write_file?" asks somebody to translate an implementation + * detail before they can decide, and a question nobody understands gets answered yes. + */ +const ASK_VERBS: Record = { + activate: "press", + type: "type into", + navigate: "open", + read: "look at", + read_file: "read", + write_file: "write to", + list_files: "list", + read_tool: "call", + write_tool: "call", +}; + +/** + * The question itself: what is about to happen, in one sentence. + * + * The rule that asked is deliberately not in here. It travels beside the question as its own field, + * so the surface can show it as a rule and the trail can record it as one, and a person reading the + * prompt is not made to parse CEL before they can answer a question about a button. + */ +function describeAsk(context: PolicyContext): string { + return `The Bot wants to ${ASK_VERBS[context.intent ?? ""] ?? "act on"} ${subjectOf(context)}.`; +} + /** A refusal a person can act on: what was refused, and on what. */ function describeRefusal(context: PolicyContext, expression: string): string { - // A file refusal must not be phrased as happening "on ": the workspace has nothing to do with - // whatever page the browser happens to be showing, and saying so sends somebody to the wrong place. - if (context.file) { + // A file or tool refusal must not be phrased as happening "on ": neither the workspace nor + // somebody else's server has anything to do with whatever page the browser happens to be showing, + // and saying so sends somebody to the wrong place. + if (context.mcp) { + return ( + `This deployment's policy does not allow that: ${subjectOf(context)} ` + + `is blocked by the rule \`${expression}\`.` + ); + } + if (context.file?.path) { return ( `This deployment's policy does not allow that: the file ${context.file.path} ` + `is blocked by the rule \`${expression}\`.` @@ -310,3 +409,22 @@ function describeRefusal(context: PolicyContext, expression: string): string { `is blocked by the rule \`${expression}\`.` ); } + +/** + * The thing an action is aimed at, named the way the person who has to decide would name it. + * + * Every branch is checked for content rather than presence, because a caller judging something that + * is not a browser action fills the browser fields in with empty strings on purpose: a rule about + * element names must evaluate to false against a tool call rather than becoming unevaluable and + * therefore matching. Reading those blanks as "a file" produced a question with a hole in it, "The + * Bot wants to call .", which is a sentence nobody can answer and which arrived attached to two + * buttons. + */ +function subjectOf(context: PolicyContext): string { + if (context.mcp) return `${context.mcp.tool} on ${context.mcp.server}`; + if (context.file?.path) return context.file.path; + if (context.element?.name) { + return `“${context.element.name}” on ${context.page.host}`; + } + return context.page.host || "this page"; +} diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 3ce1c28..f468e48 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -13,6 +13,7 @@ import { } from "./client"; import { type ActionActor, + ActionNeedsApprovalError, ActionRefusedError, type ComputerGateway, } from "./gateway"; @@ -62,6 +63,7 @@ export function createComputerRoutes( routes.post("/:botId/navigate", requireUser, async (context) => { const body = (await context.req.json().catch(() => null)) as { url?: unknown; + approvalId?: unknown; } | null; if (typeof body?.url !== "string" || !body.url.trim()) { return context.json({ error: "A web address is required." }, 400); @@ -79,9 +81,13 @@ export function createComputerRoutes( : { userId: context.var.actor.id }), }, body.url.trim(), + asApprovalId(body), ), ); } catch (error) { + if (error instanceof ActionNeedsApprovalError) { + return awaitingApproval(context, error); + } if (error instanceof ActionRefusedError) { return context.json({ error: error.message, rule: error.rule }, 403); } @@ -108,12 +114,23 @@ export function createComputerRoutes( * * Each one hands the gateway the computer id, the Bot, the actor and the input, and does no checking * of its own beyond the shape of the request. Where a decision gets made is a single place. + * + * Each also passes through whatever `approvalId` the body carried. The route does not look at it + * or judge it: an approval means something only against the action the gateway is about to take, + * and a route that decided anything about it would be a second place deciding. */ routes.post("/:botId/click", requireUser, (context) => act(context, (botId, actor, body, signal) => { const ref = asRef(body); if (!ref) return badRef; - return gateway.click(botId, botId, actor, ref, signal); + return gateway.click( + botId, + botId, + actor, + ref, + signal, + asApprovalId(body), + ); }), ); @@ -134,6 +151,7 @@ export function createComputerRoutes( submit: body?.submit === true, }, signal, + asApprovalId(body), ); }), ); @@ -153,15 +171,22 @@ export function createComputerRoutes( ...(ref ?? {}), }, signal, + asApprovalId(body), ); }), ); routes.post("/:botId/scroll", requireUser, (context) => act(context, (botId, actor, body) => - gateway.scroll(botId, botId, actor, { - ...(typeof body?.deltaY === "number" ? { deltaY: body.deltaY } : {}), - }), + gateway.scroll( + botId, + botId, + actor, + { + ...(typeof body?.deltaY === "number" ? { deltaY: body.deltaY } : {}), + }, + asApprovalId(body), + ), ), ); @@ -299,11 +324,17 @@ export function createComputerRoutes( /** The Bot's files. Through the gateway, like every other acting call. */ routes.post("/:botId/files/list", requireUser, (context) => act(context, (botId, actor, body) => - gateway.listFiles(botId, botId, actor, { - ...(typeof body?.path === "string" && body.path.trim() - ? { path: body.path.trim() } - : {}), - }), + gateway.listFiles( + botId, + botId, + actor, + { + ...(typeof body?.path === "string" && body.path.trim() + ? { path: body.path.trim() } + : {}), + }, + asApprovalId(body), + ), ), ); @@ -312,7 +343,13 @@ export function createComputerRoutes( if (typeof body?.path !== "string" || !body.path.trim()) { return { error: "A file path is required." }; } - return gateway.readFile(botId, botId, actor, { path: body.path.trim() }); + return gateway.readFile( + botId, + botId, + actor, + { path: body.path.trim() }, + asApprovalId(body), + ); }), ); @@ -324,11 +361,17 @@ export function createComputerRoutes( if (typeof body?.contents !== "string") { return { error: "The contents to write are required." }; } - return gateway.writeFile(botId, botId, actor, { - path: body.path.trim(), - contents: body.contents, - append: body.append === true, - }); + return gateway.writeFile( + botId, + botId, + actor, + { + path: body.path.trim(), + contents: body.contents, + append: body.append === true, + }, + asApprovalId(body), + ); }), ); @@ -440,6 +483,9 @@ async function act( } return context.json(result as Record); } catch (error) { + if (error instanceof ActionNeedsApprovalError) { + return awaitingApproval(context, error); + } // A policy refusal is the product working. 403 with the rule that refused it, so the surface can // tell the person which boundary they met rather than reporting a malfunction. if (error instanceof ActionRefusedError) { @@ -477,6 +523,44 @@ function isBadRequest(value: unknown): value is BadRequest { ); } +/** + * A boundary that wants a person, reported as 409 rather than 403. + * + * 403 already means one thing to everything downstream of here: a boundary refused you and that is + * final. The surface renders it as Blocked and the model is told to stop and say so. This is the + * opposite condition, nothing has been refused and somebody is being asked, so reusing 403 would + * make every ask rule read to a Bot as a deny rule and produce exactly the outcome the ask list + * exists to avoid: a turn thrown away on an action the deployment was willing to permit. + * + * 409 because the existing 409s on these routes already mean "not now, and here is what to do about + * it", which a stale snapshot and a person holding the wheel both are. `awaitingApproval` is what + * separates this from those, and the surface checks for it before it reads a 409 as anything else. + */ +function awaitingApproval( + context: ComputerContext, + error: ActionNeedsApprovalError, +) { + return context.json( + { + error: error.message, + awaitingApproval: true, + approvalId: error.approvalId, + question: error.question, + rule: error.rule, + }, + 409, + ); +} + +/** An answer being presented, if the caller carried one. Its meaning is decided at the gateway. */ +function asApprovalId( + body: Record | null, +): string | undefined { + return typeof body?.approvalId === "string" && body.approvalId + ? body.approvalId + : undefined; +} + function asRef( body: Record | null, ): { ref: string; snapshotId: number } | undefined { diff --git a/server/src/db/schema/computer.ts b/server/src/db/schema/computer.ts index 517384e..231f9d1 100644 --- a/server/src/db/schema/computer.ts +++ b/server/src/db/schema/computer.ts @@ -26,6 +26,15 @@ export const actionPolicy = pgTable("action_policy", { /** `enforce` or `dry-run`. Not an enum: the policy module owns that vocabulary. */ mode: text("mode").notNull(), deny: text("deny").array().notNull(), + /** + * The rules that stop and ask a person, rather than deciding on their own. + * + * Defaulted to empty rather than left nullable, so a deployment whose row was written before this + * list existed comes back up meaning what it meant: no questions, same two answers. A nullable + * column would put the same reasoning in every reader instead, and one of them would eventually + * read null as something other than "asks nobody anything". + */ + ask: text("ask").array().notNull().default([]), allow: text("allow").array().notNull(), /** Who last changed it, for the Admin page and the trail. */ updatedBy: text("updated_by"), diff --git a/server/src/index.ts b/server/src/index.ts index 45dcaa2..695d98f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,7 @@ import { createThreadIdentity } from "./channels/thread-identity"; import { websocket as channelSocket } from "./channels/socket"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; +import { createApprovalRegistry } from "./computer/approvals"; import { createComputerClient } from "./computer/client"; import { createComputerGateway } from "./computer/gateway"; import { @@ -197,12 +198,23 @@ const bootAuditStore = createAuditStore(database); */ const sandboxedStore = createSandboxedStore(database, bootAuditStore); +/** + * The one place a Bot's unanswered questions live. + * + * Built here rather than inside either thing that raises them, because a deployment has one of + * these and two things that ask: a Bot meeting an `ask` rule on a button and the same Bot meeting + * one on a tool call are the same interruption to the same person, and a registry per subsystem + * would mean the surface somebody happens to be looking at decides which of them they can answer. + */ +const approvals = createApprovalRegistry(); + const pluginStore = createPluginStore({ database, auditStore: bootAuditStore, credentials: credentialStore, encryptionKey: config.keyEncryptionKey, policy: () => policyStore.get(), + approvals, }); void recordAuditEvent(bootAuditStore, { @@ -344,6 +356,7 @@ const app = createApp( // Read on every decision rather than captured once, so a rule an administrator adds while the // server is running applies to the very next action instead of after a restart. policy: () => policyStore.get(), + approvals, // Stop, reset and the listing act on containers when there are containers to act on. ...(supervisor ? { supervisor } : {}), // Only when a deployment has said its Bots retry on a slower rhythm than the built-in window @@ -373,6 +386,8 @@ const app = createApp( sandboxedStore, // How a thread that has no channel is named, so the direct Bot chat is in the same namespace. threadIdentity, + // Where a person answers what the boundary stopped to ask, whichever half of the product asked. + approvals, ); /** diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 59c6c2a..bfa5348 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -6,6 +6,7 @@ import { CATALOGUE } from "./catalogue"; import { CatalogueEntryUnknownError, CustomServerRefusedError, + PluginNeedsApprovalError, PluginRefusedError, type PluginStore, } from "./store"; @@ -348,6 +349,7 @@ export function createPluginRoutes( ref?: string; args?: Record; agentId?: string; + approvalId?: unknown; } | null; if (!body?.ref || !body.agentId) { return context.json({ error: "A tool and a Bot are required." }, 400); @@ -359,9 +361,36 @@ export function createPluginRoutes( args: body.args ?? {}, botId: body.agentId, actorId: actorEmail(context), + // Passed through without being looked at. An approval means something only against the call + // the store is about to make, and a route that judged it would be a second place deciding. + ...(typeof body.approvalId === "string" && body.approvalId + ? { approvalId: body.approvalId } + : {}), }); return context.json(result); } catch (error) { + /** + * A boundary that wants a person, reported as 409 rather than 403. + * + * 403 already means one thing to everything downstream: a boundary refused you, and that is + * final. The surface renders it as a refusal and the model is told to stop and say so. This is + * the opposite condition, so reusing 403 would make every ask rule about a tool call read to a + * Bot as a deny rule, and the turn would be thrown away on work the deployment was willing to + * permit. The same status and the same body shape the computer's acting routes use, because + * the surface waits for both in the same way. + */ + if (error instanceof PluginNeedsApprovalError) { + return context.json( + { + error: error.message, + awaitingApproval: true, + approvalId: error.approvalId, + question: error.question, + rule: error.rule, + }, + 409, + ); + } if (error instanceof PluginRefusedError) { return context.json({ error: error.message, rule: error.rule }, 403); } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 66fc4b4..9470678 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1,5 +1,10 @@ import { and, asc, eq, inArray, isNull, or } from "drizzle-orm"; import { type AuditStore, recordAuditEvent } from "../audit"; +import { + type ApprovalRegistry, + fingerprintOf, + type PendingApproval, +} from "../computer/approvals"; import { type ActionPolicy, evaluateActionPolicy, @@ -117,6 +122,33 @@ export class PluginRefusedError extends Error { } } +/** + * The boundary wants a person's answer before this call is made. + * + * Emphatically not a {@link PluginRefusedError}, for the same reason the computer keeps the two + * apart. A refusal is final and a model told it was refused should say so and stop; this is a pause, + * and the identical call arriving again with an approval on it is the intended next step rather than + * an attempt to get around anything. Collapsing them teaches a model to abandon exactly the work a + * deployment was willing to permit, which is what makes an ask list that degrades to a deny list + * worse than having no ask list at all. + */ +export class PluginNeedsApprovalError extends Error { + /** What the caller presents once somebody has answered. */ + readonly approvalId: string; + /** The question in the words a person is being shown, so the Bot can say what it is waiting for. */ + readonly question: string; + /** The rule that asked, so the surface can name the boundary the way a refusal does. */ + readonly rule: string; + + constructor(approval: PendingApproval) { + super(approval.question); + this.name = "PluginNeedsApprovalError"; + this.approvalId = approval.id; + this.question = approval.question; + this.rule = approval.rule; + } +} + export class CatalogueEntryUnknownError extends Error { constructor(key: string) { super(`${key} is not a server this deployment will connect to.`); @@ -159,6 +191,15 @@ export type PluginStoreOptions = { encryptionKey: string; /** Read at call time, never captured, so a policy changed a moment ago applies to this call. */ policy: () => ActionPolicy; + /** + * Where a question raised by the `ask` list waits for an answer. + * + * Required rather than optional, and the same registry the computer uses. Optional would mean a + * store built without one, which is easily done, quietly turning every ask rule about a tool call + * into a refusal: the failure this whole path exists to avoid, arriving through a field somebody + * forgot to pass. + */ + approvals: ApprovalRegistry; }; export function createPluginStore(options: PluginStoreOptions) { @@ -212,6 +253,74 @@ export function createPluginStore(options: PluginStoreOptions) { return { row, entry }; } + /** + * Spend a person's answer on this call, or stop and ask them. + * + * The fingerprint covers the arguments as well as the tool, which is the difference between this + * and the browser actions. A click is identified by the thing it lands on; a call to somebody + * else's server is identified by what it says, and an approval for "post the release note in the + * team channel" that could be spent on any other message to any other channel would be a + * confirmation prompt wearing a governance feature's clothes. + * + * Returns who allowed it. Throws when nobody has, which every unsuccessful presentation counts as: + * an expired id, one already spent, a No being replayed and an approval given for a different call + * all mean that nobody has agreed to THIS, and asking again is both the safe answer and the one a + * person can act on. + */ + async function askAbout(question: { + approvalId: string | undefined; + botId: string; + actorId: string; + ref: string; + serverId: string; + toolName: string; + effect: "read" | "write"; + args: Record; + rule: string; + question: string; + }): Promise { + const fingerprint = fingerprintOf({ + botId: question.botId, + toolName: toolNameFor(question.ref), + arguments: question.args, + }); + const presented = question.approvalId + ? options.approvals.consume(question.approvalId, fingerprint) + : undefined; + // An approval with nobody's name on it asks again rather than being credited to whoever was + // driving the Bot, which is the one attribution this record must never make. + if (presented?.ok && presented.approval.answeredBy) { + return presented.approval.answeredBy; + } + + const pending = options.approvals.request({ + botId: question.botId, + actor: question.actorId, + rule: question.rule, + question: question.question, + fingerprint, + // Filed against the tool, so the answer's row lands beside the call's own row rather than + // under whichever surface the person happened to press the button on. + target: { type: "mcp_tool", id: question.ref }, + }); + await recordAuditEvent(auditStore, { + eventType: "approval.requested", + targetType: "mcp_tool", + targetId: question.ref, + payload: { + bot: question.botId, + actor: question.actorId, + approval: pending.id, + rule: pending.rule, + reason: pending.question, + server: question.serverId, + tool: question.toolName, + effect: question.effect, + }, + }); + throw new PluginNeedsApprovalError(pending); + } + return { /** * Add a server from the catalogue. @@ -736,6 +845,14 @@ export function createPluginStore(options: PluginStoreOptions) { args: Record; botId: string; actorId: string; + /** + * An answer a person already gave, presented for the call it was given for. + * + * The same contract the acting routes on the computer have, and for the same reason: the id + * alone proves nothing, it is the id together with the fingerprint of the call actually being + * made that means anything. + */ + approvalId?: string | undefined; }): Promise<{ text: string; isError: boolean }> { const [serverId, ...rest] = input.ref.split("/"); const toolName = rest.join("/"); @@ -789,10 +906,14 @@ export function createPluginStore(options: PluginStoreOptions) { * that rule is unevaluable, so it would match, so every deployment using the shipped preset * would refuse every MCP call for a reason mentioning a submit button. * - * Neutral values instead. Empty strings match no substring, no key and no extension, so a rule - * written about the browser evaluates to false against a tool call, which is the honest answer: - * a tool call did not click anything. A rule meant to catch tool calls says so, with `mcp` or - * with `intent`. + * Neutral values instead. Empty strings match no substring, no key and no extension, and + * `submit` is false because a tool call submits no form, so a rule written about the browser + * evaluates to false against a tool call, which is the honest answer: a tool call did not click + * anything. A rule meant to catch tool calls says so, with `mcp` or with `intent`. + * + * The blanks are for the policy engine and never for a person: the sentence a question is + * phrased in reads them as absent rather than as an empty file path, or somebody would be + * asked to approve "The Bot wants to call ." */ const context: PolicyContext = { tool: { name: toolNameFor(input.ref) }, @@ -807,6 +928,7 @@ export function createPluginStore(options: PluginStoreOptions) { repeat: { count: 1 }, element: { ref: "", role: "", name: "", type: "" }, key: "", + submit: false, file: { path: "", name: "", extension: "" }, intent: effect === "write" ? "write_tool" : "read_tool", mcp: { server: serverId, tool: toolName, effect }, @@ -814,8 +936,43 @@ export function createPluginStore(options: PluginStoreOptions) { const verdict = evaluateActionPolicy(options.policy(), context); + /** + * The third answer, handled here as well as on the computer. + * + * An `ask` verdict is `forward: false` in enforce mode, so a call site that knows only about + * yes and no reads it as a refusal, and the list an operator wrote to be asked about silently + * becomes a list of things their Bots may never do. That is the exact failure the ask list + * exists to prevent, and it is worse here than it looks: the boundary editor and the shipped + * configuration both offer `ask` as a general third list, and "ask me before anything changes + * anything in Jira" is the first rule most deployments reach for. + * + * So the same shape as the gateway: spend an approval that fits this exact call, or open the + * question and stop. Nothing is recorded as succeeded or rejected in the second case, because + * neither happened yet. + */ + const approved = + verdict.source === "ask" && !verdict.forward + ? await askAbout({ + approvalId: input.approvalId, + botId: input.botId, + actorId: input.actorId, + ref: input.ref, + serverId, + toolName, + effect, + args, + rule: verdict.matched ?? "", + question: verdict.reason, + }) + : undefined; + + // What the boundary settled on once a person's answer is folded in. The source stays `ask`, so + // the row reads as "allowed, because somebody was asked and said yes" rather than as an + // ordinary permission nobody ever questioned. + const carriedOut = verdict.forward || approved !== undefined; + await recordAuditEvent(auditStore, { - eventType: verdict.forward ? "mcp.call_succeeded" : "mcp.call_rejected", + eventType: carriedOut ? "mcp.call_succeeded" : "mcp.call_rejected", targetType: "mcp_tool", targetId: input.ref, payload: { @@ -825,16 +982,17 @@ export function createPluginStore(options: PluginStoreOptions) { tool: toolName, effect, decision: { - allowed: verdict.allowed, + allowed: verdict.allowed || approved !== undefined, mode: verdict.mode, rule: verdict.matched, source: verdict.source, - carriedOut: verdict.forward, + carriedOut, + ...(approved ? { approvedBy: approved } : {}), }, }, }); - if (!verdict.forward) { + if (!carriedOut) { throw new PluginRefusedError(verdict.reason, verdict.matched); } diff --git a/server/tests/approval-routes.test.ts b/server/tests/approval-routes.test.ts new file mode 100644 index 0000000..94c4d39 --- /dev/null +++ b/server/tests/approval-routes.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { createApprovalRoutes } from "../src/computer/approval-routes"; +import { + type ApprovalRegistry, + createApprovalRegistry, + fingerprintOf, +} from "../src/computer/approvals"; +import type { ComputerClient } from "../src/computer/client"; +import { + ActionNeedsApprovalError, + createComputerGateway, +} from "../src/computer/gateway"; +import type { ActionPolicy } from "../src/computer/policy"; +import type { SnapshotResult } from "../src/computer/schema"; + +/** + * The surface a person answers on, exercised as the browser reaches it. + * + * Tested through the routes rather than through the registry alone, because the two things that go + * wrong here are things only the handler can get wrong: which Bot an answer is recorded against, and + * what the reply carries back out of the process. Neither is visible from the registry's own tests, + * and both are the sort of thing a sibling handler quietly diverges on. + */ + +const SNAPSHOT: SnapshotResult = { + snapshotId: 7, + url: "https://example.com/order", + title: "Order", + truncated: false, + elements: [ + { ref: "e1", role: "input", name: "Customer name:", type: "text" }, + { ref: "e9", role: "button", name: "Submit order" }, + ], +}; + +const ASKING: ActionPolicy = { + mode: "enforce", + deny: [], + ask: ['contains(element.name, "submit")'], + allow: ["true"], +}; + +/** The person whose turn raised the question. Not the person who answers it. */ +const DRIVER = { id: "dev-local-user" }; + +const MANAGER = { + id: "manager-user", + email: "manager@openbot.test", + role: "user", +} as const; + +function fakeClient() { + const calls: string[] = []; + const client = { + snapshot: async () => SNAPSHOT, + click: async () => { + calls.push("click"); + return { action: "click", url: SNAPSHOT.url, elapsedMs: 1 } as never; + }, + forBot() { + return client; + }, + } as unknown as ComputerClient; + return { client, calls }; +} + +/** + * One audit store and one registry behind both halves, which is the arrangement being tested. + * + * The question is raised by the gateway and answered on the routes, minutes apart and by different + * people, and the only thing joining the two rows is the approval id. A test that gave each half its + * own store could not see whether they agree. + */ +async function surface() { + const rows: AuditEventInput[] = []; + const auditStore: AuditStore = { + insert: async (event) => void rows.push(event), + }; + const approvals: ApprovalRegistry = createApprovalRegistry(); + const { client, calls } = fakeClient(); + const gateway = createComputerGateway({ + client, + auditStore, + policy: () => ASKING, + approvals, + }); + await gateway.snapshot("bot-1"); + + const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", MANAGER); + await next(); + }; + const app = new Hono<{ Variables: AppVariables }>(); + app.route("/", createApprovalRoutes(approvals, auditStore, requireUser)); + + /** A click that meets the ask rule, and the question it leaves open. */ + const ask = async (botId: string) => + (await gateway + .click(botId, botId, DRIVER, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + return { app, approvals, gateway, ask, rows, calls }; +} + +const answer = + (app: Hono<{ Variables: AppVariables }>) => + async (botId: string, approvalId: string, granted: boolean) => + app.request(`/${botId}/${approvalId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ granted }), + }); + +describe("answering a question", () => { + test("records the answer under the person who gave it and lets the action run", async () => { + const { app, ask, rows, gateway, calls } = await surface(); + const asked = await ask("bot-1"); + + const response = await answer(app)("bot-1", asked.approvalId, true); + expect(response.status).toBe(200); + + await gateway.click( + "bot-1", + "bot-1", + DRIVER, + { ref: "e9", snapshotId: 7 }, + undefined, + asked.approvalId, + ); + + expect(calls).toEqual(["click"]); + // Three rows for one action, and each answers a different question: it was asked, somebody said + // yes, it happened. + expect(rows.map((row) => row.eventType)).toEqual([ + "approval.requested", + "approval.granted", + "computer.action_allowed", + ]); + // The answer is credited to whoever answered, not to whoever was driving the Bot, which is the + // one thing an approval trail must never get wrong. + expect(rows[0]?.payload.actor).toBe("dev-local-user"); + expect(rows[1]?.payload.actor).toBe("manager-user"); + expect(rows[1]?.actorUserId).toBe("manager-user"); + expect(rows[1]?.payload.asked).toBe("dev-local-user"); + const decision = rows[2]?.payload.decision as { approvedBy?: string }; + expect(decision.approvedBy).toBe("manager-user"); + }); + + test("files the answer against the Bot the question was about, not the address it arrived at", async () => { + // The Bot in the path is whatever the caller typed. Taking it from the request would put a grant + // in the trail under one Bot and the action it paid for under another, joined by an id that + // appears on both and reconciles neither. + const { app, ask, rows } = await surface(); + const asked = await ask("bot-1"); + + const wrongBot = await answer(app)("bot-2", asked.approvalId, true); + expect(wrongBot.status).toBe(409); + expect(rows.map((row) => row.eventType)).toEqual(["approval.requested"]); + + expect((await answer(app)("bot-1", asked.approvalId, true)).status).toBe( + 200, + ); + expect(rows[1]?.targetId).toBe("bot-1"); + expect(rows[1]?.payload.bot).toBe("bot-1"); + }); + + test("never sends the binding out of the process", async () => { + // The fingerprint is what ties an approval to one action, and it is compared here. Nothing on + // the surface can do anything with it, and a handler that returned it would be quietly undoing + // the invariant its sibling four lines up is careful to keep. + const { app, ask } = await surface(); + const asked = await ask("bot-1"); + + const listed = await (await app.request("/bot-1")).json(); + const answered = await ( + await answer(app)("bot-1", asked.approvalId, true) + ).json(); + + for (const record of [ + ...(listed as { approvals: Record[] }).approvals, + answered as Record, + ]) { + expect(record.fingerprint).toBeUndefined(); + expect(record.actor).toBeUndefined(); + expect(record.target).toBeUndefined(); + expect(record.question).toContain("Submit order"); + } + }); + + test("a question nobody is waiting on any more is a conflict, not a fault", async () => { + const { app, ask } = await surface(); + const asked = await ask("bot-1"); + + expect((await answer(app)("bot-1", asked.approvalId, false)).status).toBe( + 200, + ); + // Answered once, and not answerable again: otherwise a second tab can quietly overturn a + // decision the trail has already recorded as made. + expect((await answer(app)("bot-1", asked.approvalId, true)).status).toBe( + 409, + ); + expect((await answer(app)("bot-1", "not-a-real-id", true)).status).toBe( + 409, + ); + }); + + test("refuses to read a missing answer as either one", async () => { + const { app, ask, rows } = await surface(); + const asked = await ask("bot-1"); + + const response = await app.request(`/bot-1/${asked.approvalId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(response.status).toBe(400); + expect(rows).toHaveLength(1); + }); + + test("shows one Bot's open questions and not another's", async () => { + const { app, ask } = await surface(); + const asked = await ask("bot-1"); + + const mine = (await (await app.request("/bot-1")).json()) as { + approvals: { id: string; rule: string }[]; + }; + expect(mine.approvals).toHaveLength(1); + expect(mine.approvals[0]?.id).toBe(asked.approvalId); + expect(mine.approvals[0]?.rule).toBe('contains(element.name, "submit")'); + + const theirs = (await (await app.request("/bot-2")).json()) as { + approvals: unknown[]; + }; + expect(theirs.approvals).toEqual([]); + }); + + test("a declined answer stops the action without pretending it was refused by a rule", async () => { + const { app, ask, rows, gateway, calls } = await surface(); + const asked = await ask("bot-1"); + + await answer(app)("bot-1", asked.approvalId, false); + // Presenting a No asks again rather than going through, and nothing reached the computer. + await expect( + gateway.click( + "bot-1", + "bot-1", + DRIVER, + { ref: "e9", snapshotId: 7 }, + undefined, + asked.approvalId, + ), + ).rejects.toThrow(ActionNeedsApprovalError); + expect(calls).toEqual([]); + expect(rows.map((row) => row.eventType)).toEqual([ + "approval.requested", + "approval.denied", + "approval.requested", + ]); + }); + + test("an answer is spendable only on the action it was given for", async () => { + const { app, ask, approvals } = await surface(); + const asked = await ask("bot-1"); + await answer(app)("bot-1", asked.approvalId, true); + + const elsewhere = approvals.consume( + asked.approvalId, + fingerprintOf({ + botId: "bot-1", + toolName: "computer_click", + ref: "e1", + pageUrl: SNAPSHOT.url, + }), + ); + expect(elsewhere.ok).toBe(false); + }); +}); diff --git a/server/tests/computer-approvals.test.ts b/server/tests/computer-approvals.test.ts new file mode 100644 index 0000000..7ecfe1f --- /dev/null +++ b/server/tests/computer-approvals.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, test } from "bun:test"; +import { + type ApprovalSubject, + createApprovalRegistry, + fingerprintOf, +} from "../src/computer/approvals"; + +/** + * What an approval has to mean, tested as properties rather than as a call sequence. + * + * A registry that only remembered ids would pass a naive test and be worthless: the failure it + * exists to prevent is a person allowing one thing and a model spending that permission on another, + * and nothing about that is visible from a green typecheck. So the cases here are the four ways a + * grant can be stretched beyond what somebody agreed to, plus the two ways it stops being valid. + */ + +const CLICK: ApprovalSubject = { + botId: "sales-bot", + toolName: "computer_click", + ref: "e9", + pageUrl: "https://example.com/order", +}; + +function registry(clock?: { at: number }) { + return createApprovalRegistry(clock ? { now: () => clock.at } : {}); +} + +function ask( + approvals: ReturnType, + subject: ApprovalSubject = CLICK, +) { + return approvals.request({ + botId: subject.botId, + actor: "someone@example.test", + rule: 'contains(element.name, "submit")', + question: "The Bot wants to press “Submit order” on example.com.", + fingerprint: fingerprintOf(subject), + target: { type: "computer", id: subject.botId }, + }); +} + +/** Answering, on the Bot the question was asked about, which is the ordinary case. */ +function answer( + approvals: ReturnType, + id: string, + who: string, + granted: boolean, + botId = CLICK.botId, +) { + return approvals.answer(id, botId, who, granted); +} + +describe("an approval", () => { + test("is spendable on the action it was granted for", () => { + const approvals = registry(); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", true); + + const spent = approvals.consume(pending.id, fingerprintOf(CLICK)); + expect(spent.ok).toBe(true); + if (spent.ok) + expect(spent.approval.answeredBy).toBe("manager@example.test"); + }); + + test("is refused for a DIFFERENT action, which is the whole point of it", () => { + // "Yes, click Place order" must not be replayable as "yes, click Delete account". Without this + // the feature is a dialog box that returns a token good for anything. + const approvals = registry(); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", true); + + const elsewhere = approvals.consume( + pending.id, + fingerprintOf({ ...CLICK, ref: "e42" }), + ); + expect(elsewhere.ok).toBe(false); + if (!elsewhere.ok) expect(elsewhere.reason).toBe("a different action"); + }); + + test("survives a failed replay, so the person's answer is not lost with it", () => { + // A mismatch leaves the approval alone. Burning it would let a model that reached for the wrong + // button take away permission for the one somebody actually meant. + const approvals = registry(); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", true); + approvals.consume(pending.id, fingerprintOf({ ...CLICK, ref: "e42" })); + + expect(approvals.consume(pending.id, fingerprintOf(CLICK)).ok).toBe(true); + }); + + test("is good exactly once", () => { + const approvals = registry(); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", true); + + expect(approvals.consume(pending.id, fingerprintOf(CLICK)).ok).toBe(true); + const again = approvals.consume(pending.id, fingerprintOf(CLICK)); + expect(again.ok).toBe(false); + if (!again.ok) expect(again.reason).toBe("unknown"); + }); + + test("cannot be spent before anybody has answered", () => { + const approvals = registry(); + const pending = ask(approvals); + + const early = approvals.consume(pending.id, fingerprintOf(CLICK)); + expect(early.ok).toBe(false); + if (!early.ok) expect(early.reason).toBe("unanswered"); + }); + + test("a No is an answer, and it is final", () => { + const approvals = registry(); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", false); + + const declined = approvals.consume(pending.id, fingerprintOf(CLICK)); + expect(declined.ok).toBe(false); + if (!declined.ok) expect(declined.reason).toBe("declined"); + }); + + test("cannot be answered twice, so a decision cannot be quietly overturned", () => { + const approvals = registry(); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", false); + + const second = answer(approvals, pending.id, "somebody@example.test", true); + expect(second.ok).toBe(false); + expect(approvals.consume(pending.id, fingerprintOf(CLICK)).ok).toBe(false); + }); + + test("runs out, and stops being answerable when it does", () => { + const clock = { at: Date.parse("2026-01-01T09:00:00.000Z") }; + const approvals = createApprovalRegistry({ + now: () => clock.at, + ttlMs: 60_000, + }); + const pending = ask(approvals); + expect(approvals.pending("sales-bot")).toHaveLength(1); + + clock.at += 60_001; + // Swept on the way past rather than on a timer, so a question nobody answered leaves no trace + // that could later be mistaken for one somebody did. + expect(approvals.pending("sales-bot")).toHaveLength(0); + expect(answer(approvals, pending.id, "late@example.test", true).ok).toBe( + false, + ); + expect(approvals.consume(pending.id, fingerprintOf(CLICK)).ok).toBe(false); + }); + + test("an already-granted approval expires too", () => { + const clock = { at: Date.parse("2026-01-01T09:00:00.000Z") }; + const approvals = createApprovalRegistry({ + now: () => clock.at, + ttlMs: 60_000, + }); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", true); + + clock.at += 60_001; + // Consent to something that was going to happen now is not consent to it happening in an hour. + expect(approvals.consume(pending.id, fingerprintOf(CLICK)).ok).toBe(false); + }); +}); + +describe("an approval belongs to one Bot", () => { + const SAME_ACTION_OTHER_BOT: ApprovalSubject = { + ...CLICK, + botId: "research-bot", + }; + + test("cannot be spent by another Bot doing the identical thing", () => { + // The Bot id is inside the fingerprint, so two Bots clicking the same ref on the same page + // produce two different bindings. Otherwise a permission given on one computer would carry over + // to a computer with different logins and a different person's session in it. + const approvals = registry(); + const pending = ask(approvals); + answer(approvals, pending.id, "manager@example.test", true); + + const elsewhere = approvals.consume( + pending.id, + fingerprintOf(SAME_ACTION_OTHER_BOT), + ); + expect(elsewhere.ok).toBe(false); + if (!elsewhere.ok) expect(elsewhere.reason).toBe("a different action"); + }); + + test("cannot be answered from another Bot's address", () => { + // The id is enough to find the question, so this is not authorisation, it is the trail: the row + // an answer writes says which Bot it was about, and it is taken from the request. Without this + // check a grant lands under one Bot and the action it pays for under another, and filtering the + // audit page by either shows half the story. + const approvals = registry(); + const pending = ask(approvals); + + const elsewhere = approvals.answer( + pending.id, + "research-bot", + "mallory@example.test", + true, + ); + expect(elsewhere.ok).toBe(false); + // Still open, and still answerable by somebody who arrived at the right address. + expect(answer(approvals, pending.id, "manager@example.test", true).ok).toBe( + true, + ); + }); + + test("does not show up in another Bot's pending list", () => { + const approvals = registry(); + ask(approvals); + expect(approvals.pending("research-bot")).toEqual([]); + expect(approvals.pending("sales-bot")).toHaveLength(1); + }); +}); + +describe("the fingerprint", () => { + test("is the same for the same action and different for every changed part", () => { + expect(fingerprintOf(CLICK)).toBe(fingerprintOf({ ...CLICK })); + for (const changed of [ + { ...CLICK, botId: "other" }, + { ...CLICK, toolName: "computer_key" }, + { ...CLICK, ref: "e10" }, + { ...CLICK, key: "Enter" }, + { ...CLICK, filePath: "notes.md" }, + { ...CLICK, pageUrl: "https://example.com/other" }, + // Typing into a field and typing into it then pressing Enter are two different actions, and + // the second one submits the form. An approval for one must not be spendable as the other. + { ...CLICK, submit: true }, + { ...CLICK, arguments: { channel: "#general" } }, + ]) { + expect(fingerprintOf(changed)).not.toBe(fingerprintOf(CLICK)); + } + }); + + test("reads the same arguments the same way whatever order they arrive in", () => { + // A tool call goes through a parse between being asked about and being retried, and an approval + // that stopped fitting because a client wrote its fields in another order would send somebody a + // second question about the call they just allowed. + expect( + fingerprintOf({ + botId: "b", + toolName: "t", + arguments: { channel: "#general", text: "shipped" }, + }), + ).toBe( + fingerprintOf({ + botId: "b", + toolName: "t", + arguments: { text: "shipped", channel: "#general" }, + }), + ); + }); + + test("tells one set of arguments from another", () => { + // The reason arguments are in here at all: "post the release note in the team channel" is not + // permission to post something else somewhere else. + expect( + fingerprintOf({ + botId: "b", + toolName: "t", + arguments: { channel: "#general", text: "shipped" }, + }), + ).not.toBe( + fingerprintOf({ + botId: "b", + toolName: "t", + arguments: { channel: "#board", text: "shipped" }, + }), + ); + }); + + test("cannot be made to collide by shuffling where a boundary falls", () => { + // The parts are joined with a separator no field can contain, so a ref of "ab" with no key is a + // different action from a ref of "a" with a key of "b". + expect(fingerprintOf({ botId: "b", toolName: "t", ref: "ab" })).not.toBe( + fingerprintOf({ botId: "b", toolName: "t", ref: "a", key: "b" }), + ); + }); +}); diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index db7576b..6de1937 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; import type { AuditEventInput, AuditStore } from "../src/audit"; +import { createApprovalRegistry } from "../src/computer/approvals"; import type { ComputerClient } from "../src/computer/client"; import { + ActionNeedsApprovalError, ActionRefusedError, createComputerGateway, } from "../src/computer/gateway"; @@ -105,7 +107,14 @@ function fakeAudit() { } const ACTOR = { id: "dev-local-user" }; -const PERMISSIVE: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +/** A second person, with a real users row, so the approval rows can be told apart by who wrote them. */ +const MANAGER = { id: "manager-user", userId: "manager-user" }; +const PERMISSIVE: ActionPolicy = { + mode: "enforce", + deny: [], + ask: [], + allow: ["true"], +}; async function gatewayWith( policy: ActionPolicy | undefined, @@ -114,15 +123,20 @@ async function gatewayWith( ) { const { client, calls } = fakeClient(); const { store, rows } = fakeAudit(); + // The registry the deployment shares between everything that can ask. Held here so these tests can + // answer a question the way a person does, without going through the surface they answer it on; + // that surface has its own tests. + const approvals = createApprovalRegistry(); const gateway = createComputerGateway({ client, auditStore: store, policy: () => policy, + approvals, ...(repeat ? { repeat } : {}), }); // Every test acts on refs, so the server must hold a snapshot first, exactly as the real flow does. await gateway.snapshot("default"); - return { gateway, calls, rows }; + return { gateway, approvals, calls, rows }; } describe("the computer gateway", () => { @@ -232,6 +246,7 @@ describe("the computer gateway", () => { const { gateway, calls, rows } = await gatewayWith({ mode: "dry-run", deny: ['contains(element.name, "submit")'], + ask: [], allow: ["true"], }); @@ -407,6 +422,264 @@ describe("the computer gateway", () => { }); }); +/** + * The path where the boundary stops and asks. + * + * Everything above proves the gateway can say yes or no. These prove it can say "not yet", which is + * a harder thing to get right: the action must not reach the computer, the trail must show the + * question rather than a verdict nobody reached, and the answer must only work for the action it was + * given for. That last one is why the error carries an id at all. + */ +describe("the gateway when the boundary asks a person", () => { + const ASKING: ActionPolicy = { + mode: "enforce", + deny: [], + ask: ['contains(element.name, "submit")'], + allow: ["true"], + }; + + test("stops the action and asks, rather than refusing it", async () => { + const { gateway, calls, rows } = await gatewayWith(ASKING); + + const error = await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ActionNeedsApprovalError); + // Not a refusal. A Bot told it was blocked gives up and says so; this one is meant to wait. + expect(error).not.toBeInstanceOf(ActionRefusedError); + expect(calls).toEqual([]); + + // One row, and it is the question. Nothing was allowed and nothing was refused, so writing + // either would put a verdict in the trail that the policy never reached. + expect(rows).toHaveLength(1); + expect(rows[0]?.eventType).toBe("approval.requested"); + expect(rows[0]?.payload.rule).toBe('contains(element.name, "submit")'); + expect(rows[0]?.payload.action).toBe("computer_click"); + expect(rows[0]?.payload.approval).toBe( + (error as ActionNeedsApprovalError).approvalId, + ); + }); + + test("carries the action out once a person has allowed it", async () => { + const { gateway, approvals, calls, rows } = await gatewayWith(ASKING); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + approvals.answer(asked.approvalId, "bot-1", MANAGER.id, true); + await gateway.click( + "default", + "bot-1", + ACTOR, + { ref: "e9", snapshotId: 7 }, + undefined, + asked.approvalId, + ); + + expect(calls).toEqual(["click"]); + // Two rows here, and the third is written where the answer was given: the question, and the + // action it turned into. The action's own row says a person stood behind it and names them, + // rather than reading as an ordinary permission nobody ever questioned. + expect(rows.map((row) => row.eventType)).toEqual([ + "approval.requested", + "computer.action_allowed", + ]); + const decision = rows[1]?.payload.decision as { + source?: string; + approvedBy?: string; + }; + expect(decision.source).toBe("ask"); + expect(decision.approvedBy).toBe("manager-user"); + }); + + test("an approval for one action does not carry to another", async () => { + // The grant is bound to a fingerprint of the exact call. Pressing a different thing on the same + // page with the same id in hand asks again rather than going through, which is the difference + // between an approval and a token good for anything. + const { gateway, approvals, calls } = await gatewayWith({ + ...ASKING, + ask: ["true"], + }); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + approvals.answer(asked.approvalId, "bot-1", MANAGER.id, true); + + await expect( + gateway.click( + "default", + "bot-1", + ACTOR, + { ref: "e1", snapshotId: 7 }, + undefined, + asked.approvalId, + ), + ).rejects.toThrow(ActionNeedsApprovalError); + expect(calls).toEqual([]); + }); + + test("a declined request asks again rather than acting", async () => { + const { gateway, approvals, calls, rows } = await gatewayWith(ASKING); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + approvals.answer(asked.approvalId, "bot-1", MANAGER.id, false); + await expect( + gateway.click( + "default", + "bot-1", + ACTOR, + { ref: "e9", snapshotId: 7 }, + undefined, + asked.approvalId, + ), + ).rejects.toThrow(ActionNeedsApprovalError); + + expect(calls).toEqual([]); + // A No leaves a second question rather than a refusal row: nobody has agreed to this, and the + // trail says so where it happened. + expect(rows[1]?.eventType).toBe("approval.requested"); + }); + + test("a type call that will press Enter is judged as one that submits", async () => { + // The third door into a form. The computer presses Enter itself when the Bot asks it to, so no + // keypress ever reaches the gateway as an action of its own, and a boundary written about + // clicking and about `key` let the one call that submits a single-field form through. + const { gateway, calls } = await gatewayWith({ + ...PERMISSIVE, + ask: ["submit"], + }); + + await expect( + gateway.type("default", "bot-1", ACTOR, { + ref: "e1", + snapshotId: 7, + text: "SW1A 1AA", + submit: true, + }), + ).rejects.toThrow(ActionNeedsApprovalError); + expect(calls).toEqual([]); + + // Filling the same field in without submitting is not the same action and is not asked about. + await gateway.type("default", "bot-1", ACTOR, { + ref: "e1", + snapshotId: 7, + text: "SW1A 1AA", + }); + expect(calls).toEqual(["type"]); + }); + + test("an answer given for typing is not spendable on typing and submitting", async () => { + // Submitting is the escalation the binding has to cover: "yes, fill the postcode in" is not + // "yes, fill it in and send the form". + const { gateway, approvals, calls } = await gatewayWith({ + ...PERMISSIVE, + ask: ["true"], + }); + const asked = (await gateway + .type("default", "bot-1", ACTOR, { + ref: "e1", + snapshotId: 7, + text: "SW1A 1AA", + }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + approvals.answer(asked.approvalId, "bot-1", MANAGER.id, true); + + await expect( + gateway.type( + "default", + "bot-1", + ACTOR, + { ref: "e1", snapshotId: 7, text: "SW1A 1AA", submit: true }, + undefined, + asked.approvalId, + ), + ).rejects.toThrow(ActionNeedsApprovalError); + expect(calls).toEqual([]); + }); + + test("the question is visible to the surface while it is open", async () => { + const { gateway, approvals } = await gatewayWith(ASKING); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + const waiting = approvals.pending("bot-1"); + expect(waiting).toHaveLength(1); + expect(waiting[0]?.id).toBe(asked.approvalId); + expect(waiting[0]?.question).toContain("Submit order"); + // Another Bot's screen must not offer somebody a question about this one's computer. + expect(approvals.pending("bot-2")).toEqual([]); + }); + + test("dry-run records the question and lets the action through", async () => { + // Dry-run promises a policy that changes nothing, so an ask rule under it interrupts nobody. The + // row is what an operator switched dry-run on to read. + const { gateway, calls, rows } = await gatewayWith({ + ...ASKING, + mode: "dry-run", + }); + + await gateway.click("default", "bot-1", ACTOR, { + ref: "e9", + snapshotId: 7, + }); + + expect(calls).toEqual(["click"]); + expect(rows).toHaveLength(1); + expect(rows[0]?.eventType).toBe("computer.action_refused"); + const decision = rows[0]?.payload.decision as { + source?: string; + carriedOut?: boolean; + }; + expect(decision.source).toBe("ask"); + expect(decision.carriedOut).toBe(true); + }); + + test("a deny beats an ask on the same action", async () => { + // Precedence, through the gateway rather than only in the evaluator: a forbidden thing is refused + // outright and is never put in front of a person as a question they could say yes to. + const { gateway, calls, rows } = await gatewayWith({ + ...ASKING, + deny: ['contains(element.name, "submit")'], + }); + + await expect( + gateway.click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }), + ).rejects.toThrow(ActionRefusedError); + expect(calls).toEqual([]); + expect(rows[0]?.eventType).toBe("computer.action_refused"); + }); + + test("a file write can be held back the same way a click can", async () => { + const { gateway, calls, rows } = await gatewayWith({ + mode: "enforce", + deny: [], + ask: ['intent == "write_file" && !matches(file.path, "^notes/")'], + allow: ["true"], + }); + + await expect( + gateway.writeFile("default", "bot-1", ACTOR, { + path: "reports/august.csv", + contents: "anything", + }), + ).rejects.toThrow(ActionNeedsApprovalError); + expect(calls).toEqual([]); + expect(rows[0]?.payload.file).toBe("reports/august.csv"); + + // A path the rule leaves alone still goes straight through, or the rule would be a deny wearing + // a different name. + await gateway.writeFile("default", "bot-1", ACTOR, { + path: "notes/today.md", + contents: "anything", + }); + expect(calls).toEqual(["writeFile"]); + }); +}); + /** * The gateway is the only place that can count this, which is why it does. * diff --git a/server/tests/computer-policy.test.ts b/server/tests/computer-policy.test.ts index c2c8efe..d9bb383 100644 --- a/server/tests/computer-policy.test.ts +++ b/server/tests/computer-policy.test.ts @@ -30,7 +30,12 @@ function context(overrides: Partial = {}): PolicyContext { }; } -const permissive: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +const permissive: ActionPolicy = { + mode: "enforce", + deny: [], + ask: [], + allow: ["true"], +}; describe("evaluateActionPolicy", () => { test("an absent policy refuses, rather than permitting everything", () => { @@ -42,7 +47,7 @@ describe("evaluateActionPolicy", () => { test("an empty allow list refuses", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: [], allow: [] }, + { mode: "enforce", deny: [], ask: [], allow: [] }, context(), ); expect(decision.allowed).toBe(false); @@ -93,7 +98,7 @@ describe("evaluateActionPolicy", () => { test("a broken allow expression does not permit", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: [], allow: ["also not ( valid"] }, + { mode: "enforce", deny: [], ask: [], allow: ["also not ( valid"] }, context(), ); expect(decision.allowed).toBe(false); @@ -105,6 +110,7 @@ describe("evaluateActionPolicy", () => { { mode: "dry-run", deny: ['contains(element.name, "submit")'], + ask: [], allow: ["true"], }, context(), @@ -164,10 +170,43 @@ describe("parseActionPolicy", () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.policy.deny).toEqual([]); + expect(result.policy.ask).toEqual([]); expect(result.policy.allow).toEqual([]); } }); + test("a policy written before the ask list existed still parses", () => { + // Every deployment already running has a saved policy with two lists in it. Rejecting one, or + // reading its absence as anything other than "asks nobody anything", would change what an + // existing boundary means at the moment the server came back up. + const result = parseActionPolicy({ + mode: "enforce", + deny: ['contains(element.name, "pay")'], + allow: ["true"], + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.policy.ask).toEqual([]); + }); + + test("keeps the ask rules it was given", () => { + const result = parseActionPolicy({ + mode: "enforce", + deny: [], + ask: ['intent == "write_file"'], + allow: ["true"], + }); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.policy.ask).toEqual(['intent == "write_file"']); + }); + + test("rejects an ask list that is not a list of expressions", () => { + expect(parseActionPolicy({ mode: "enforce", ask: "everything" }).ok).toBe( + false, + ); + expect(parseActionPolicy({ mode: "enforce", ask: [7] }).ok).toBe(false); + }); + test.each([ ["not an object", "nonsense"], ["a missing mode", { deny: [], allow: [] }], @@ -188,6 +227,7 @@ describe("the second door", () => { const policy = { mode: "enforce" as const, deny: ['tool.name == "computer_key" && key == "Enter"'], + ask: [], allow: ["true"], }; const refused = evaluateActionPolicy(policy, { @@ -211,6 +251,45 @@ describe("the second door", () => { }); expect(allowed.allowed).toBe(true); }); + + test("a rule can refuse the type tool's own Enter, which is neither a click nor a keypress", () => { + // The type tool takes a flag meaning "and submit", and the computer presses Enter itself, so no + // keypress ever arrives as an action of its own. A boundary written about clicking and about + // `key` watched the one call that submits a single-field form go straight past it. + const policy = { + mode: "enforce" as const, + deny: [ + '(intent == "activate" && contains(element.name, "submit")) || (tool.name == "computer_key" && key == "Enter") || submit', + ], + ask: [], + allow: ["true"], + }; + const typing = (submit: boolean): PolicyContext => ({ + tool: { name: "computer_type" }, + bot: { id: "sales" }, + actor: { id: "someone" }, + page: { url: "https://example.com/order", host: "example.com" }, + element: { ref: "e6", role: "input", name: "Postcode" }, + intent: "type", + submit, + }); + + expect(evaluateActionPolicy(policy, typing(true)).allowed).toBe(false); + // Filling the field in is still allowed, or the rule would stop the Bot typing at all. + expect(evaluateActionPolicy(policy, typing(false)).allowed).toBe(true); + // And the same rule stays evaluable on an action that cannot submit anything, which is why the + // field is on every context rather than only on the calls that can set it. + expect( + evaluateActionPolicy(policy, { + tool: { name: "computer_scroll" }, + bot: { id: "sales" }, + actor: { id: "someone" }, + page: { url: "https://example.com/order", host: "example.com" }, + intent: "read", + submit: false, + }).allowed, + ).toBe(true); + }); }); /** @@ -231,6 +310,7 @@ describe("a rule written about what an action does", () => { const policy = { mode: "enforce" as const, deny: ['intent == "activate" && contains(element.name, "submit")'], + ask: [], allow: ["true"], }; @@ -320,7 +400,7 @@ describe("a rule that names an identifier only some actions carry", () => { test("unguarded, it refuses a navigation that has no key at all", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: ['key == "Enter"'], allow: ["true"] }, + { mode: "enforce", deny: ['key == "Enter"'], ask: [], allow: ["true"] }, navigating, ); // Failing closed on an unevaluable rule is the safe answer. The shipped preset carries the guard @@ -333,6 +413,7 @@ describe("a rule that names an identifier only some actions carry", () => { { mode: "enforce", deny: ['tool.name == "computer_key" && key == "Enter"'], + ask: [], allow: ["true"], }, navigating, @@ -345,6 +426,7 @@ describe("a rule that names an identifier only some actions carry", () => { { mode: "enforce", deny: ['tool.name == "computer_key" && key == "Enter"'], + ask: [], allow: ["true"], }, { @@ -359,6 +441,175 @@ describe("a rule that names an identifier only some actions carry", () => { }); }); +/** + * The third answer, and the order it is asked in. + * + * Precedence is the whole design here and none of it is visible from the types. An ask that could + * soften a deny would let a person wave through something a deployment forbade; an ask evaluated + * after allow would never fire at all, because the shipped policy permits everything. Both mistakes + * produce a rule that looks configured and does nothing anybody intended, so both are tested against + * the permissive default deployments actually run. + */ +describe("asking a person", () => { + const asking: ActionPolicy = { + ...permissive, + ask: ['contains(element.name, "submit")'], + }; + + test("an ask beats allow, in the configuration everybody ships with", () => { + const decision = evaluateActionPolicy(asking, context()); + expect(decision.source).toBe("ask"); + expect(decision.allowed).toBe(false); + // Nothing happens until somebody says so. + expect(decision.forward).toBe(false); + expect(decision.matched).toBe('contains(element.name, "submit")'); + }); + + test("a deny beats an ask, so a forbidden thing is never offered as a question", () => { + const decision = evaluateActionPolicy( + { ...asking, deny: ['contains(element.name, "submit")'] }, + context(), + ); + expect(decision.source).toBe("deny"); + }); + + test("an ask rule leaves everything else alone", () => { + const decision = evaluateActionPolicy( + asking, + context({ element: { ref: "e6", role: "input", name: "Quantity" } }), + ); + expect(decision.source).toBe("allow"); + expect(decision.allowed).toBe(true); + }); + + test("a BROKEN ask expression asks, rather than quietly permitting", () => { + // Fail-closed, the same way a broken deny denies. A typo in the rule interrupts somebody, which + // is a nuisance; the alternative is that `allow: ["true"]` waves through exactly the action the + // rule was written to hold back, and nothing anywhere says so. + const decision = evaluateActionPolicy( + { ...permissive, ask: ["this is not ( valid cel"] }, + context(), + ); + expect(decision.source).toBe("ask"); + expect(decision.forward).toBe(false); + }); + + test("a rule about an element still asks when the element is unknown", () => { + // `contains` on a missing field throws, and a throwing ask expression asks. An action on + // something the server could not resolve is exactly the case a person should look at. + const decision = evaluateActionPolicy( + asking, + context({ element: undefined }), + ); + expect(decision.source).toBe("ask"); + }); + + test("dry-run records the question and interrupts nobody", () => { + // The promise of dry-run is that switching a policy on changes nothing, so an ask there is a note + // saying where somebody would have been stopped, not a stop. + const decision = evaluateActionPolicy( + { ...asking, mode: "dry-run" }, + context(), + ); + expect(decision.source).toBe("ask"); + expect(decision.allowed).toBe(false); + expect(decision.forward).toBe(true); + }); + + test("the question names what is about to happen, and not the rule", () => { + // The rule travels as its own field. A person answering a question about a button should not + // have to read CEL to work out what they are agreeing to. + const decision = evaluateActionPolicy( + asking, + context({ intent: "activate" }), + ); + expect(decision.reason).toContain("Submit order"); + expect(decision.reason).toContain("example.com"); + expect(decision.reason).not.toContain("contains("); + }); + + test("a file question names the file rather than whatever page is open", () => { + const decision = evaluateActionPolicy( + { ...permissive, ask: ['intent == "write_file"'] }, + context({ + tool: { name: "computer_write_file" }, + intent: "write_file", + element: undefined, + file: { + path: "reports/august.csv", + name: "august.csv", + extension: "csv", + }, + }), + ); + expect(decision.reason).toContain("reports/august.csv"); + expect(decision.reason).not.toContain("example.com"); + }); + + test("a question about a tool call names the tool and the server", () => { + // The context a tool call is judged in fills the browser fields with empty strings on purpose, + // so that a rule about element names evaluates to false rather than becoming unevaluable. Read + // as though a file were present, that produced "The Bot wants to call ." in front of a person, + // with two buttons under it. + const decision = evaluateActionPolicy( + { ...permissive, ask: ['intent == "write_tool"'] }, + { + tool: { name: "mcp__jira__editJiraIssue" }, + bot: { id: "b" }, + actor: { id: "a" }, + page: { url: "", host: "" }, + element: { ref: "", role: "", name: "", type: "" }, + key: "", + submit: false, + file: { path: "", name: "", extension: "" }, + intent: "write_tool", + mcp: { server: "jira", tool: "editJiraIssue", effect: "write" }, + }, + ); + expect(decision.source).toBe("ask"); + expect(decision.reason).toBe( + "The Bot wants to call editJiraIssue on jira.", + ); + }); + + test("a refusal of a tool call names it the same way", () => { + const decision = evaluateActionPolicy( + { ...permissive, deny: ['mcp.server == "jira"'] }, + { + tool: { name: "mcp__jira__editJiraIssue" }, + bot: { id: "b" }, + actor: { id: "a" }, + page: { url: "", host: "" }, + element: { ref: "", role: "", name: "", type: "" }, + key: "", + submit: false, + file: { path: "", name: "", extension: "" }, + intent: "write_tool", + mcp: { server: "jira", tool: "editJiraIssue", effect: "write" }, + }, + ); + expect(decision.reason).toContain("editJiraIssue on jira"); + // Nothing about a file, and nothing about whatever page a browser happens to be showing. + expect(decision.reason).not.toContain("the file"); + }); + + test("an empty allow list still refuses what nobody asked about", () => { + // The floor is unchanged. An ask list is a third answer, not a way of turning default-deny into + // default-ask: an action no rule mentions is still refused rather than put to somebody. + const decision = evaluateActionPolicy( + { + mode: "enforce", + deny: [], + ask: ['tool.name == "computer_write_file"'], + allow: [], + }, + context(), + ); + expect(decision.source).toBe("default"); + expect(decision.allowed).toBe(false); + }); +}); + /** * The one attribute that separates the thirtieth click on a button from the first. * @@ -372,6 +623,7 @@ describe("a rule about a Bot repeating itself", () => { const repeating: ActionPolicy = { mode: "enforce", deny: ["repeat.count >= 10"], + ask: [], allow: ["true"], }; diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 3349ab8..38ff4a6 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; +import { createApprovalRegistry } from "../src/computer/approvals"; import type { ActionPolicy } from "../src/computer/policy"; import { createDatabase } from "../src/db/client"; import { TEST_POOL } from "./support/database"; @@ -12,7 +13,11 @@ import { mcpTools, pluginGrants, } from "../src/db/schema"; -import { createPluginStore, PluginRefusedError } from "../src/plugins/store"; +import { + createPluginStore, + PluginNeedsApprovalError, + PluginRefusedError, +} from "../src/plugins/store"; /** * The two questions a tool call has to pass, and the row each answer leaves behind. @@ -47,6 +52,9 @@ let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; */ let serverWasAlreadyConfigured = false; +/** The deployment's one registry, shared with the computer in a real deployment. */ +const approvals = createApprovalRegistry(); + const store = createPluginStore({ database, auditStore: createAuditStore(database), @@ -56,6 +64,7 @@ const store = createPluginStore({ }, encryptionKey: "x".repeat(44), policy: () => policy, + approvals, }); async function auditRowsFor(targetId: string) { @@ -279,6 +288,131 @@ describe("a boundary written about the browser does not refuse tool calls", () = }); }); +/** + * The third answer, which the tool-call path has to give as well as the computer. + * + * An ask verdict does not forward, so a call site that only knows yes and no reads it as a refusal + * and the list an operator wrote to be asked about silently becomes a list of things their Bots may + * never do. That is the failure the ask list exists to prevent, and it is invisible from a green + * typecheck: everything still compiles, the audit trail still fills up, and the rows say refused. + */ +describe("a boundary can ask a person about a tool call", () => { + test("stops the call and asks, rather than refusing it", async () => { + await store.grant("mcp", ref, holderId, "admin@openbot.local"); + policy = { + mode: "enforce", + deny: [], + ask: ['mcp.server == "atlassian"'], + allow: ["true"], + }; + + let thrown: unknown; + try { + await store.callTool({ + ref, + args: { query: "open bugs" }, + botId: holderId, + actorId: "someone@openbot.local", + }); + } catch (error) { + thrown = error; + } finally { + policy = { mode: "enforce", deny: [], allow: ["true"] }; + } + + expect(thrown).toBeInstanceOf(PluginNeedsApprovalError); + // A refusal is final and a model told it was refused gives up; this one is meant to come back. + expect(thrown).not.toBeInstanceOf(PluginRefusedError); + const asked = thrown as PluginNeedsApprovalError; + // The question names the tool and the server. It used to read "The Bot wants to call ." here, + // because the neutral file path this context carries was mistaken for a real one. + expect(asked.question).toBe( + "The Bot wants to call searchJiraIssues on atlassian.", + ); + expect(asked.rule).toBe('mcp.server == "atlassian"'); + + const rows = await auditRowsFor(ref); + const question = rows.filter( + (row) => + row.eventType === "approval.requested" && + (row.payload as { approval?: string }).approval === asked.approvalId, + ); + expect(question).toHaveLength(1); + // Nothing is recorded as rejected, because nothing was: the turn stopped at the question. A + // deny rule earlier in this file leaves rejections behind, so what is asserted is that none of + // them came from the ask list. + expect( + rows.some( + (row) => + row.eventType === "mcp.call_rejected" && + (row.payload as { decision?: { source?: string } }).decision + ?.source === "ask", + ), + ).toBe(false); + }); + + test("an answer is good for the call it was given for, and not for another", async () => { + await store.grant("mcp", ref, holderId, "admin@openbot.local"); + policy = { + mode: "enforce", + deny: [], + ask: ['mcp.server == "atlassian"'], + allow: ["true"], + }; + const call = { + ref, + args: { query: "open bugs" }, + botId: holderId, + actorId: "someone@openbot.local", + }; + + try { + const asked = (await store + .callTool(call) + .catch((error: unknown) => error)) as PluginNeedsApprovalError; + approvals.answer( + asked.approvalId, + holderId, + "manager@openbot.local", + true, + ); + + // The arguments are inside the binding, so a person who allowed one message to one channel has + // not allowed a different one. This asks again rather than going through. + const elsewhere = await store + .callTool({ + ...call, + args: { query: "everything" }, + approvalId: asked.approvalId, + }) + .catch((error: unknown) => error); + expect(elsewhere).toBeInstanceOf(PluginNeedsApprovalError); + + // The call it was actually given for gets past the boundary, which it proves by failing at the + // network instead of as a question or a refusal. + const allowed = await store + .callTool({ ...call, approvalId: asked.approvalId }) + .catch((error: unknown) => error); + expect(allowed).not.toBeInstanceOf(PluginNeedsApprovalError); + expect(allowed).not.toBeInstanceOf(PluginRefusedError); + + const rows = await auditRowsFor(ref); + // The row for the allowed call names who stood behind it, so the trail reads as "somebody was + // asked and said yes" rather than as an ordinary permission nobody ever questioned. + expect( + rows.some( + (row) => + row.eventType === "mcp.call_succeeded" && + (row.payload as { decision?: { approvedBy?: string } }).decision + ?.approvedBy === "manager@openbot.local", + ), + ).toBe(true); + } finally { + policy = { mode: "enforce", deny: [], allow: ["true"] }; + } + }); +}); + describe("the trail can be read by a second reader", () => { test("a refusal names the bot, the server and the tool in queryable JSON", async () => { const [row] = await database diff --git a/server/tests/policy-durability.integration.test.ts b/server/tests/policy-durability.integration.test.ts index d767297..e385963 100644 --- a/server/tests/policy-durability.integration.test.ts +++ b/server/tests/policy-durability.integration.test.ts @@ -38,7 +38,7 @@ describe("a boundary set while running", () => { const before = createPolicyStore(configured, database); await before.load(); await before.set( - { mode: "enforce", deny: [rule], allow: ["true"] }, + { mode: "enforce", deny: [rule], ask: [], allow: ["true"] }, "admin@example.test", ); @@ -47,6 +47,24 @@ describe("a boundary set while running", () => { expect(after.get().deny).toEqual([rule]); }); + test("the rules that ask a person survive a restart too", async () => { + // The list that stops and asks has to be as durable as the one that refuses. A boundary that + // silently stopped asking after a deployment came back up would be indistinguishable, from the + // trail, from one whose questions were all answered yes. + const asking = 'intent == "write_file" && !matches(file.path, "^notes/")'; + const before = createPolicyStore(configured, database); + await before.set({ + mode: "enforce", + deny: [], + ask: [asking], + allow: ["true"], + }); + + const after = createPolicyStore(configured, database); + expect(await after.load()).toBe("the database"); + expect(after.get().ask).toEqual([asking]); + }); + test("a deployment that never set one gets its configured default", async () => { const store = createPolicyStore(configured, database); expect(await store.load()).toBe("configuration"); @@ -55,7 +73,12 @@ describe("a boundary set while running", () => { test("resetting forgets it, so a restart returns to configuration", async () => { const store = createPolicyStore(configured, database); - await store.set({ mode: "enforce", deny: [rule], allow: ["true"] }); + await store.set({ + mode: "enforce", + deny: [rule], + ask: [], + allow: ["true"], + }); await store.reset(); // The saved row is removed rather than overwritten, so changing what configuration says then @@ -67,8 +90,18 @@ describe("a boundary set while running", () => { test("setting twice keeps one row and the latest rule", async () => { const store = createPolicyStore(configured, database); - await store.set({ mode: "enforce", deny: ["first"], allow: ["true"] }); - await store.set({ mode: "dry-run", deny: ["second"], allow: ["true"] }); + await store.set({ + mode: "enforce", + deny: ["first"], + ask: [], + allow: ["true"], + }); + await store.set({ + mode: "dry-run", + deny: ["second"], + ask: [], + allow: ["true"], + }); const rows = await database.select().from(actionPolicy); // One boundary per deployment, by construction. Two rows would mean something has to choose. @@ -80,7 +113,7 @@ describe("a boundary set while running", () => { test("records who changed it", async () => { const store = createPolicyStore(configured, database); await store.set( - { mode: "enforce", deny: [rule], allow: ["true"] }, + { mode: "enforce", deny: [rule], ask: [], allow: ["true"] }, "admin@example.test", ); @@ -93,7 +126,12 @@ describe("a boundary set while running", () => { // bigger problems than an unsaved rule. const store = createPolicyStore(configured); expect(await store.load()).toBe("configuration"); - await store.set({ mode: "enforce", deny: [rule], allow: ["true"] }); + await store.set({ + mode: "enforce", + deny: [rule], + ask: [], + allow: ["true"], + }); expect(store.get().deny).toEqual([rule]); await store.reset(); expect(store.get()).toEqual(configured);