diff --git a/.env.example b/.env.example index 465d645..3205c40 100644 --- a/.env.example +++ b/.env.example @@ -123,63 +123,26 @@ 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 everything. An empty `allow` permits nothing, a missing policy +# `deny` is evaluated first and beats `allow`. 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, 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 -# perfectly reasonable on its own terms; only the count tells the thirtieth click apart from the -# first. `repeat.count >= 10` in `deny` stops a Bot going in circles. Two calls are the same call -# when the thing acted on is the same, whatever was typed into it, so ten searches typed into one box -# are ten repeats and a rule about repetition refuses the tenth: try one in `dry-run` first. The -# count is held in memory by the process that served the call, so a deployment running two API -# replicas splits every count and a rule about ten attempts fires at twenty or never, and calls to -# another server's tools over MCP are not counted at all. +# key, file.path, file.name, file.extension. # # 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, 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. +# that only blocks a Submit button does not block Enter from another field. The example below refuses +# Enter outright for that reason. # 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\") || 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. -# Widen it for a deployment whose provider is slow or heavily queued, where genuine retries arrive -# minutes apart and every attempt would otherwise be counted as the first one. Widen it too far and -# honest work starts to accumulate: a Bot told to watch a dashboard all morning reloads the same page -# and is not stuck. Anything that is not a positive whole number stops the server rather than falling -# back to the default, because a rule about repetition that never fires looks exactly like a Bot -# behaving itself. -# COMPUTER_REPEAT_WINDOW_MS=180000 +# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"allow":["true"]} # How long one action waits for its element, in ms. Read by agent-computer, not the server. # ACTION_TIMEOUT_MS=10000 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..389183f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ +## What this changes + + + +## Where it runs + +OpenBot is deployed as several server processes behind a load balancer, serving a whole company. +Consecutive requests from the same person reach different processes, and the process that answered a +WebSocket upgrade is rarely the one that answers the next call on that conversation. + +State that outlives a single request therefore has to be shared, or the change works on one machine +and stops working the moment there are two, without saying so. That failure is worse than not +shipping the feature: it passes review, passes CI, passes a local demo, and only surfaces as a Bot +that forgets, a question nobody can answer, or a boundary that never fires. + +Answer these even when the answer is "none": + +- [ ] **New state that outlives a request?** Where does it live? A `Map` or `Set` held in a module or + a factory closure does not count as somewhere. +- [ ] **What happens on the second replica?** Name the concrete outcome, not "should be fine". +- [ ] **Anything serialised?** Say what stops two processes doing it at once. A unique index, a + conditional update, or an advisory lock are answers. A check-then-write is not. +- [ ] **Anything fanned out to a browser?** Say how it reaches a socket held by another process. +- [ ] **New listener, port, or schedule?** Say how it is reached through the same ingress as the API, + and what a hundred copies of it do. + +Postgres is already there and is the default answer to all of the above: a table, a unique index, a +conditional update, `LISTEN`/`NOTIFY` for fan-out. + +## Boundary and audit + +- [ ] Every acting call still goes through the gateway: resolve, decide, audit, then act. +- [ ] New refusals and new failures each write a row. +- [ ] Nothing new is trusted from the client that the server can resolve itself. + +## Proof + + diff --git a/app/src/components/channels/approval-request.tsx b/app/src/components/channels/approval-request.tsx deleted file mode 100644 index abc1bdb..0000000 --- a/app/src/components/channels/approval-request.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index cf84610..0000000 --- a/app/src/lib/approvals.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 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 bea0484..ce480b1 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -1,13 +1,11 @@ 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"; @@ -18,16 +16,6 @@ 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. */ @@ -54,91 +42,7 @@ 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, @@ -171,19 +75,6 @@ async function sendToComputer( > | 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.", @@ -249,27 +140,14 @@ 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; @@ -279,16 +157,13 @@ function ActionLine({ failed?: boolean; }) { return ( - <> - - - + ); } @@ -309,7 +184,11 @@ export function ComputerTools() { parameters: z.object({ url: z.string().describe("Full web address to open, including https://"), }), - handler: async ({ url }: { url: string }, call: ToolCallContext = {}) => { + handler: async ( + { url }: { url: string }, + // Context is optional in the SDK. + { signal }: { signal?: AbortSignal } = {}, + ) => { const result = await callComputer( bot.current, "/navigate", @@ -318,7 +197,7 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify({ url }), }, - call, + signal, ); return result.ok ? { @@ -330,14 +209,8 @@ export function ComputerTools() { } : result; }, - render: ({ status, toolCallId }) => ( + render: ({ status }) => (
- {/* - * 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. - */} -
), @@ -405,7 +278,7 @@ export function ComputerTools() { text: string; submit?: boolean; }, - call: ToolCallContext = {}, + { signal }: { signal?: AbortSignal } = {}, ) => callComputer( bot.current, @@ -415,11 +288,10 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - call, + signal, ), - render: ({ args, result, status, toolCallId }) => ( + render: ({ args, result, status }) => ( callComputer( bot.current, @@ -458,13 +330,12 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - call, + signal, ), - render: ({ args, result, status, toolCallId }) => { + render: ({ args, result, status }) => { const outcome = outcomeOf(result); return ( callComputer( bot.current, @@ -510,11 +381,10 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - call, + signal, ), - render: ({ args, result, status, toolCallId }) => ( + render: ({ args, result, status }) => ( { const botId = bot.current; const asked = await callComputer( @@ -559,7 +429,7 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - call, + signal, ); if (!asked.ok) return asked; @@ -567,7 +437,7 @@ export function ComputerTools() { const outcome = await waitForPerson( botId, (state) => state.secretWanted === undefined, - call.signal, + signal, ); return { ok: true, @@ -603,7 +473,7 @@ export function ComputerTools() { }), handler: async ( input: { reason: string; request?: string }, - call: ToolCallContext = {}, + { signal }: { signal?: AbortSignal } = {}, ) => { try { const response = await fetch( @@ -613,7 +483,7 @@ export function ComputerTools() { credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify(input), - ...(call.signal ? { signal: call.signal } : {}), + ...(signal ? { signal } : {}), }, ); return response.ok @@ -641,7 +511,10 @@ 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 }, call: ToolCallContext = {}) => { + handler: async ( + input: { reason: string }, + { signal }: { signal?: AbortSignal } = {}, + ) => { const botId = bot.current; const asked = await callComputer( botId, @@ -651,7 +524,7 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - call, + signal, ); if (!asked.ok) return asked; @@ -659,7 +532,7 @@ export function ComputerTools() { const outcome = await waitForPerson( botId, (state) => state.holder === "bot" && !state.requested, - call.signal, + signal, ); return { ok: true, @@ -687,23 +560,17 @@ export function ComputerTools() { .optional() .describe("Optional folder to list. Omit for the whole workspace."), }), - 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 }) => { + handler: async (input: { path?: string }) => + callComputer(bot.current, "/files/list", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input ?? {}), + }), + render: ({ result, status }) => { 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), - }, - call, - ), - render: ({ args, result, status, toolCallId }) => { + handler: async (input: { path: string }) => + callComputer(bot.current, "/files/read", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + render: ({ args, result, status }) => { const outcome = outcomeOf(result); return ( - callComputer( - bot.current, - "/files/write", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - }, - call, - ), - render: ({ args, result, status, toolCallId }) => { + handler: async (input: { + path: string; + contents: string; + append?: boolean; + }) => + callComputer(bot.current, "/files/write", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + render: ({ args, result, status }) => { const outcome = outcomeOf(result); return ( + handler: async ( + input: { deltaY?: number }, + { signal }: { signal?: AbortSignal } = {}, + ) => callComputer( bot.current, "/scroll", @@ -840,11 +695,10 @@ export function ComputerTools() { headers: { "content-type": "application/json" }, body: JSON.stringify(input), }, - call, + signal, ), - render: ({ result, status, toolCallId }) => ( + render: ({ result, status }) => ( - {/* - * 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} - - + + {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 a74d6ea..aad4c73 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,5 +1,4 @@ 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 = { @@ -122,100 +121,18 @@ 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, - // 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 } : {}), - }), + body: JSON.stringify({ ref, args, agentId }), ...(signal ? { signal } : {}), }); @@ -224,9 +141,6 @@ async function sendCall( isError?: boolean; error?: string; rule?: string | null; - awaitingApproval?: boolean; - approvalId?: string; - question?: string; } | null; if (response.ok) { @@ -236,16 +150,6 @@ async function sendCall( 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 84a7b80..2a6319b 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -35,11 +35,9 @@ const FILTERS = [ { label: "Computer actions", search: "?eventType=computer.action_allowed" }, { label: "Blocked", - // 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. + // Include every refusal family, not only browser policy refusals. search: - "?eventType=computer.action_refused,approval.denied,mcp.call_rejected,component.refused,component.function_refused", + "?eventType=computer.action_refused,mcp.call_rejected,component.refused,component.function_refused", }, { label: "Did not happen", @@ -47,21 +45,6 @@ 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. - label: "Going in circles", - search: "?eventType=computer.action_repeated", - }, ] as const; function AuditPage() { @@ -149,7 +132,6 @@ function Row({ allowed?: boolean; mode?: string; rule?: string | null; - approvedBy?: string; carriedOut?: boolean; }; const element = payload.element as @@ -158,13 +140,9 @@ 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 @@ -195,10 +173,6 @@ function Row({ ) : null} - ) : typeof payload.fingerprint === "string" ? ( - // A repeat row has no element and no file of its own: what it is about is the call, which - // the fingerprint names in full. - {payload.fingerprint} ) : typeof payload.file === "string" ? ( {payload.file} ) : typeof element === "object" && element?.name ? ( @@ -259,22 +233,11 @@ function Row({ , reported by the Bot itself ) : null} - {event.eventType === "computer.action_repeated" && - typeof payload.count === "number" ? ( -
- {payload.count} times within a few minutes -
- ) : null} {failed && typeof payload.failure === "string" ? (
{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 @@ -289,17 +252,6 @@ 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 @@ -337,11 +289,6 @@ const DECISIONS: Record = { "computer.secret_supplied": "A person supplied a secret", "computer.reset": "The computer was reset", "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 7f7470c..dd920c2 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -14,21 +14,17 @@ 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: Preset[] = [ +const PRESETS: { label: string; rule: string; cost?: string }[] = [ { label: "Never submit a form", - // 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', + // `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")', cost: "Also stops the Bot pressing Enter for anything else, because a form submits from Enter in any of its fields.", }, { @@ -36,12 +32,6 @@ const PRESETS: Preset[] = [ rule: 'intent == "type" && contains(element.name, "password")', cost: "A password box the page labels something else is not covered, the rule matches the label.", }, - { - label: "Stop a Bot repeating itself", - // The count includes the attempt being decided, so this refuses the tenth, not the eleventh. - rule: "repeat.count >= 10", - cost: "Two calls count as the same call when the thing acted on is the same, whatever was typed into it, so a Bot running ten searches from one box, or reading one file ten times, is refused on the tenth. It misses the other way too: a Bot slow enough to spread its attempts wider than a few minutes is never caught, one that changes a single argument each time is ten different calls, and calls to another server's tools are not counted at all. Worth adding while a match is recorded and allowed, before it starts refusing anybody's work.", - }, { label: "Stay off social media", rule: 'intent == "navigate" && (contains(page.host, "facebook.com") || contains(page.host, "x.com"))', @@ -49,26 +39,6 @@ const PRESETS: Preset[] = [ }, ]; -/** - * 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, }); @@ -79,7 +49,6 @@ 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 { @@ -158,20 +127,6 @@ 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 9f322b5..90b52c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,47 +54,16 @@ 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` - -`repeat.count` is how many times that Bot has just made that exact call, counting the one being -decided. The gateway keys it on the tool plus the ref, key, file path, or target URL, over a sliding -window that defaults to three minutes and is set by `COMPUTER_REPEAT_WINDOW_MS`. Crossing 3, 10, or -25 writes one `computer.action_repeated` row each; the detector itself never refuses anything, so -`repeat.count >= 10` in `deny` is what stops a Bot going in circles. The count is held in memory by -the process that served the call, so two API replicas split it, and it covers the browser and the -workspace only: a call to another server's tools over MCP always reports one. Rules use CEL expressions plus case-insensitive `contains()` and `matches()`. -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. +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. ## Computers diff --git a/docs/configuration.md b/docs/configuration.md index ba59015..0657fb2 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":[...],"ask":[...],"allow":[...]}`. | +| `AGENT_COMPUTER_POLICY` | JSON action policy: `{"mode":"enforce","deny":[...],"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 deleted file mode 100644 index 22458dd..0000000 --- a/server/drizzle/0001_gigantic_sumo.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index edd784a..0000000 --- a/server/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,2510 +0,0 @@ -{ - "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 076f20a..540bea2 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,13 +8,6 @@ "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 945f5ed..7675d47 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,8 +19,6 @@ 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"; @@ -97,15 +95,6 @@ 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 }>(); @@ -321,14 +310,6 @@ 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 69fe18c..d72250e 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -64,22 +64,6 @@ export const auditEventTypes = [ // Permitted by policy, attempted, and did not succeed. Its own type because "allowed" reads as // "happened", and a trail that cannot tell those apart misleads exactly when it matters most. "computer.action_failed", - /** - * The same call, again, and again. - * - * The rows above record actions one at a time, which is the only way to record them and the reason - * a Bot stuck in a retry loop is invisible here: thirty identical rows look like thirty rows. This - * one says the thing the sequence cannot, that these are the same call, and how many times. - * - * It is not a refusal. Nothing was forbidden and nothing was stopped; a Bot did the same thing - * again, which is often merely a retry that is about to work. Filing it as a refusal would teach a - * reader to skim past the refusals that are real, so it is its own type and the audit page gives it - * its own words. - * - * Written when a count crosses a threshold rather than on every repeat, because a row per attempt - * would bury the attempts themselves under the observation that they kept happening. - */ - "computer.action_repeated", // A person taking the wheel and giving it back. Recorded as a period rather than as keystrokes: the // useful fact for an investigator is that a human drove this browser between these two times, and // logging every click a person made would bury it while telling nobody anything. @@ -90,29 +74,6 @@ 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 deleted file mode 100644 index a4c0939..0000000 --- a/server/src/computer/approval-routes.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * 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 deleted file mode 100644 index a805a20..0000000 --- a/server/src/computer/approvals.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * 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 bbe3b71..b2337be 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -18,12 +18,6 @@ * 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, @@ -31,7 +25,6 @@ import { type PolicyContext, type PolicyDecision, } from "./policy"; -import { createRepeatDetector, type RepeatDetector } from "./repeat"; import type { ClickInput, KeyInput, @@ -57,32 +50,6 @@ 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. */ @@ -109,25 +76,6 @@ 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. - * - * Absent, the gateway makes its own, which is what almost every deployment gets. Passed in only to - * widen the window for a slow provider, or to hand a test a clock it can move, because otherwise - * proving that a window expires means a test that waits three minutes, and a test that waits three - * minutes is a test somebody eventually deletes. - */ - repeat?: RepeatDetector; }; /** @@ -147,8 +95,6 @@ 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(); /** * The computer, addressed as the Bot that is asking. @@ -208,19 +154,8 @@ 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 { @@ -234,32 +169,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { const intent = intentOf(toolName, subject.key); - /* - * Counted before the policy is asked, so that a rule written against the count decides the very - * attempt that crossed the line rather than the one after it. Off by one here would mean a - * deployment forbidding a tenth identical click allows the tenth and refuses the eleventh, which - * is the kind of thing nobody notices until they are counting rows in an incident. - * - * Reading a page never reaches this function, so nothing counts a Bot looking at the same screen - * over and over. That is the cheapest thing it does and the one nobody minds. - */ - const repetition = repeat.observe(botId, { - tool: toolName, - ref, - key: subject.key, - filePath, - targetUrl: subject.targetUrl, - }); - const context: PolicyContext = { tool: { name: toolName }, bot: { id: botId }, 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 @@ -275,120 +189,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ...(filePath ? { file: describeFile(filePath) } : {}), }; - if (repetition.threshold !== null && repetition.fingerprint) { - /* - * Ahead of the decision row, so the trail reads in the order the thing happened: this was the - * tenth identical attempt, and this is what the policy did about it. Filed the other way round - * a reader has to deduce the cause from a row written after its effect. - * - * Its failure is swallowed, which nothing else in this file does. This row is an observation, - * and an observation is not allowed to refuse anything: letting a lost insert throw from here - * would stop every third, tenth and twenty-fifth identical call before the policy had even - * been asked, so a deployment that permits an action would lose it to a moment's trouble at the - * audit store. Nothing is weakened by that. An action that was not recorded still does not - * happen, because the decision row goes to the same store a few lines below, and a store that - * is genuinely down refuses the action there. - */ - try { - await writeRepeat(auditStore, { - toolName, - botId, - actor, - computerId, - pageUrl, - filePath, - fingerprint: repetition.fingerprint, - count: repetition.count, - }); - } catch (error) { - console.error( - JSON.stringify({ - type: "computer-repeat-row-lost", - bot: botId, - fingerprint: repetition.fingerprint, - count: repetition.count, - error: String(error), - }), - ); - } - } - 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, @@ -399,12 +200,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ...(subject.key ? { key: subject.key } : {}), filePath, pageUrl, - decision: settled, - ...(approvedBy ? { approvedBy } : {}), + decision, }); - if (!settled.forward) { - throw new ActionRefusedError(settled.reason, settled.matched); + if (!decision.forward) { + throw new ActionRefusedError(decision.reason, decision.matched); } let result: T; @@ -430,8 +230,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ref, filePath, pageUrl, - decision: settled, - ...(approvedBy ? { approvedBy } : {}), + decision, failure: error instanceof Error ? error.message : "The action failed.", }); throw error; @@ -649,21 +448,13 @@ 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, ...(approvalId ? { approvalId } : {}) }, + { targetUrl: url }, () => as(botId).navigate(url), ); }, @@ -674,18 +465,13 @@ 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 } : {}), - ...(approvalId ? { approvalId } : {}), - }, + { ref: input.ref, ...(signal ? { signal } : {}) }, () => as(botId).click(input, signal), ); }, @@ -696,22 +482,13 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: TypeInput, signal?: AbortSignal, - approvalId?: string, ) { return govern( computerId, "computer_type", botId, actor, - { - 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 } : {}), - }, + { ref: input.ref, ...(signal ? { signal } : {}) }, () => as(botId).type(input, signal), ); }, @@ -722,7 +499,6 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: KeyInput, signal?: AbortSignal, - approvalId?: string, ) { return govern( computerId, @@ -731,12 +507,7 @@ 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 } : {}), - ...(approvalId ? { approvalId } : {}), - }, + { ref: input.ref, key: input.key, ...(signal ? { signal } : {}) }, () => as(botId).key(input, signal), ); }, @@ -746,15 +517,9 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ScrollInput, - approvalId?: string, ) { - return govern( - computerId, - "computer_scroll", - botId, - actor, - { ...(approvalId ? { approvalId } : {}) }, - () => as(botId).scroll(input), + return govern(computerId, "computer_scroll", botId, actor, {}, () => + as(botId).scroll(input), ); }, @@ -770,14 +535,13 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ReadFileInput, - approvalId?: string, ) { return govern( computerId, "computer_read_file", botId, actor, - { filePath: input.path, ...(approvalId ? { approvalId } : {}) }, + { filePath: input.path }, () => as(botId).readFile(input), ); }, @@ -792,17 +556,13 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ListFilesInput, - approvalId?: string, ) { return govern( computerId, "computer_list_files", botId, actor, - { - filePath: input.path ?? ".", - ...(approvalId ? { approvalId } : {}), - }, + { filePath: input.path ?? "." }, () => as(botId).listFiles(input), ); }, @@ -812,14 +572,13 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: WriteFileInput, - approvalId?: string, ) { return govern( computerId, "computer_write_file", botId, actor, - { filePath: input.path, ...(approvalId ? { approvalId } : {}) }, + { filePath: input.path }, () => as(botId).writeFile(input), ); }, @@ -914,14 +673,6 @@ 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; }, @@ -974,7 +725,6 @@ 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, }, @@ -982,49 +732,6 @@ async function write( }); } -/** - * One row for a Bot going round in circles. - * - * Separate from `write` because there is no policy decision to record. This row is an observation - * about the call that is about to be decided, not the decision, and giving it a `decision` block - * would mean inventing an answer the policy was never asked for. It is also why it is not a refusal: - * nothing was forbidden here. - * - * The fingerprint goes in as written, which is why `repeat.ts` builds a readable one. A reader - * arriving at "the same call, 25 times" needs to be told which call in the row itself. - */ -async function writeRepeat( - auditStore: AuditStore, - entry: { - toolName: string; - botId: string; - actor: ActionActor; - computerId: string; - pageUrl: string; - filePath: string | undefined; - fingerprint: string; - count: number; - }, -) { - await recordAuditEvent(auditStore, { - eventType: "computer.action_repeated", - targetType: "computer", - targetId: entry.computerId, - ...(entry.actor.userId ? { actorUserId: entry.actor.userId } : {}), - payload: { - action: entry.toolName, - bot: entry.botId, - actor: entry.actor.id, - // The page, for a browser action only. A file call has nothing to do with whatever the browser - // happens to be showing, and naming a host on that row sends a reader somewhere irrelevant, the - // same trap `describeRefusal` avoids. - ...(entry.filePath ? {} : { page: entry.pageUrl }), - fingerprint: entry.fingerprint, - count: entry.count, - }, - }); -} - /** * The host a rule can match on, or empty. * @@ -1072,55 +779,6 @@ 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 216f411..053ddd1 100644 --- a/server/src/computer/policy-store.ts +++ b/server/src/computer/policy-store.ts @@ -36,7 +36,6 @@ const CURRENT = "current"; export const DEFAULT_ACTION_POLICY: ActionPolicy = { mode: "enforce", deny: [], - ask: [], allow: ["true"], }; @@ -74,7 +73,6 @@ export function createPolicyStore( id: CURRENT, mode: next.mode, deny: next.deny, - ask: next.ask, allow: next.allow, updatedBy: by ?? null, updatedAt: new Date(), @@ -84,7 +82,6 @@ export function createPolicyStore( set: { mode: next.mode, deny: next.deny, - ask: next.ask, allow: next.allow, updatedBy: by ?? null, updatedAt: new Date(), @@ -116,7 +113,6 @@ export function createPolicyStore( current = { mode: row.mode as ActionPolicy["mode"], deny: [...row.deny], - ask: [...row.ask], allow: [...row.allow], }; return "the database"; @@ -128,7 +124,6 @@ function clone(policy: ActionPolicy): ActionPolicy { return { mode: policy.mode, deny: [...policy.deny], - ask: [...policy.ask], allow: [...policy.allow], }; } @@ -160,12 +155,8 @@ export function parseActionPolicy( }; } - const lists: Record<"deny" | "ask" | "allow", string[]> = { - deny: [], - ask: [], - allow: [], - }; - for (const key of ["deny", "ask", "allow"] as const) { + const lists: Record<"deny" | "allow", string[]> = { deny: [], allow: [] }; + for (const key of ["deny", "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.` }; @@ -173,11 +164,5 @@ export function parseActionPolicy( lists[key] = value as string[]; } - // `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 }, - }; + return { ok: true, policy: { mode, deny: lists.deny, allow: lists.allow } }; } diff --git a/server/src/computer/policy.ts b/server/src/computer/policy.ts index b453683..7b18cee 100644 --- a/server/src/computer/policy.ts +++ b/server/src/computer/policy.ts @@ -11,15 +11,8 @@ * 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, then ask, then allow. A rule that removes permission must never be + * Precedence: deny beats 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"; @@ -34,18 +27,8 @@ export type ActionPolicy = { * governance feature. */ mode: PolicyMode; - /** Evaluated first. Any expression true means refused, whatever `ask` or `allow` says. */ + /** Evaluated first. Any expression true means refused, whatever `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[]; }; @@ -63,34 +46,6 @@ export type PolicyContext = { bot: { id: string }; page: { url: string; host: string }; actor: { id: string }; - /** - * How many times this Bot has just made this exact call, counting the one being decided. - * - * A stuck model retries, and every retry is a real action on somebody's live website. Each one is - * permitted on its own terms, because each one is: the rule that would refuse the thirtieth click - * on a button would refuse the first, and refusing the first is refusing the product. Only the - * count separates them, so the count is here, and a deployment that wants to stop a Bot going in - * circles writes `repeat.count >= 10` and nothing else changes. - * - * Always present, at one on a call the Bot has not made before, so that a rule mentioning it is - * evaluable on every action. An absent field would throw inside CEL, and a deny rule that throws - * denies, so an optional `repeat` would turn one rule about repetition into a deployment that - * refuses everything. - * - * It is wrong in both directions, and a rule written against it has to be worth both. Under, three - * ways: the window is time-based, so a Bot slow enough to spread its attempts wider than the window - * never trips this, and one that varies a single argument each time round is thirty calls; the - * count is held by the process that served the call, so a deployment behind two API replicas - * splits every count and a rule about ten attempts fires at twenty or never; and a call to another - * server's tools over MCP is not counted at all, because only the computer gateway counts. - * - * Over, once, and that one costs somebody their Bot rather than their evidence. Two calls are the - * same call when the thing acted on is the same, whatever was typed into it, so ten searches typed - * into one box and one file read ten times while a Bot works through it are both ten repeats, and - * `repeat.count >= 10` refuses the tenth. It is a backstop against the loop that actually happens, - * not a guarantee, which is the argument for trying a rule about it in `dry-run` first. - */ - repeat: { count: number }; element?: { ref: string; role: string; @@ -110,22 +65,6 @@ 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. * @@ -143,8 +82,7 @@ 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. `submit` - * above covers the one case where the intention is not a guess, because the Bot asked for it. + * that must stop a submission still has to refuse Enter outright, and the preset says so. */ intent?: | "activate" @@ -200,15 +138,8 @@ 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. - * - * `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"; + /** Which list that expression came from. `default` means nothing matched and the floor applied. */ + source: "deny" | "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. */ @@ -288,7 +219,6 @@ 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 @@ -309,27 +239,6 @@ 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 { @@ -355,47 +264,11 @@ 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 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) { + // 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) { return ( `This deployment's policy does not allow that: the file ${context.file.path} ` + `is blocked by the rule \`${expression}\`.` @@ -409,22 +282,3 @@ 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/repeat.ts b/server/src/computer/repeat.ts deleted file mode 100644 index 297a523..0000000 --- a/server/src/computer/repeat.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * How many times a Bot has just made this exact call. - * - * A model that cannot get something to work retries. It clicks the same button, reloads the same - * page, writes the same file, and every one of those attempts is a real action on somebody's live - * website and a real charge against somebody's model credit. The trail records all thirty of them, - * one row at a time, with nothing to say that they are the same row thirty times over, so nobody - * notices until the other side's rate limiter does. - * - * This counts, and it does nothing else with the answer. The count goes to the policy, which is the - * one thing in this codebase allowed to refuse an action. A detector that blocked on its own would - * be a second boundary with rules of its own, invisible on the Boundaries page, unanswerable to - * `dry-run`, and impossible to switch off for the one Bot whose job really is to poll something. - * One boundary, given better information. - * - * In memory, and per process, for the same reason the gateway's snapshot cache is: it describes what - * a Bot did in the last few minutes, and after a restart that question has no useful answer. The - * loop is over, because whatever was driving it is gone too. - */ - -/** - * How long a call stays counted. - * - * Time rather than "this turn", because the gateway has no idea where a turn begins or ends. It is - * handed one action at a time by whichever route is serving the model, and nothing in that path - * carries a turn boundary; inventing one would mean threading a conversation id through every acting - * call to get a worse answer than a clock gives. - * - * Three minutes. A retry loop is a model round trip each time round — call the tool, read the - * failure, decide to try again — which is seconds, not minutes, so ten identical attempts fit inside - * this comfortably. Much longer and honest repetition starts to accumulate: a Bot told to watch a - * dashboard reloads the same page all morning and is not stuck. Much shorter and a model that thinks - * for twenty seconds between attempts never trips anything. - */ -export const DEFAULT_REPEAT_WINDOW_MS = 3 * 60_000; - -/** - * The counts worth writing a row about. - * - * Three is "this is now a pattern rather than a retry", ten is "nobody is going to fix this by - * trying again", twenty-five is "somebody should look". Each fires once, so the trail gains three - * rows for a Bot stuck all afternoon rather than a row per attempt, which would bury the actions it - * was taking under the observation that it kept taking them. - */ -export const DEFAULT_REPEAT_THRESHOLDS: readonly number[] = [3, 10, 25]; - -/** - * How many distinct calls are remembered per Bot. - * - * A Bot doing genuinely varied work never repeats anything, so every call it makes is a new key, and - * without a cap the map grows for exactly the Bots this feature has nothing to say about. - * - * Full means full. A call the Bot has not made before is not counted, and nothing still inside the - * window is dropped to make room for it, because dropping something is a guess at which key will not - * come round again and the obvious guess is the one that fails hardest. Least recently seen is - * exactly the key a Bot going round a long loop is about to make next, so a loop of more than this - * many distinct calls would lose each key one step before it returned, and a Bot in a tight circle - * would be reported as making every one of its calls for the first time. - * - * The cost is the Bot whose first sixty-four distinct calls are honest work and which only then gets - * stuck: its loop is invisible until one of those sixty-four falls out of the window, which is at - * most one window away. A blind spot that clears itself is worth more than an eviction rule that can - * be wrong for as long as the loop lasts, and it buys the other half of the bargain, that a call - * already being counted can never be pushed out by a Bot doing other things in between. - */ -export const DEFAULT_REPEAT_KEYS_PER_BOT = 64; - -/** - * How many Bots are remembered at once. - * - * The id counted against is the one in the request path. It is checked against a session and against - * nothing else, because a Bot's computer answers to whatever it is addressed as and no acting route - * resolves the id to a row in `bots` first. So the number of ids this map can be asked to hold is - * not the number of Bots somebody wrote down, it is however many a signed-in caller cares to type, - * and an uncapped map of maps would grow for the life of the process on nothing more than a loop of - * requests naming a fresh id each time. - * - * Reclaimed the way the per-Bot cap is: a Bot that has gone quiet for a whole window gives its place - * up, and while every place is held by a Bot that is still working, one more Bot is not counted. - * Two hundred and fifty-six, which is far more Bots than a deployment has acting inside any three - * minutes, and small enough that the worst case is a few megabytes rather than the whole heap. - */ -export const DEFAULT_REPEAT_BOTS = 256; - -/** - * One governed call, in the terms the detector cares about. - * - * The same fields the gateway already assembles for the policy and the audit row. Nothing is added - * to a call site to support this. - */ -export type RepeatedCall = { - tool: string; - ref?: string | undefined; - key?: string | undefined; - filePath?: string | undefined; - targetUrl?: string | undefined; -}; - -export type RepeatObservation = { - /** - * Including the call being observed, so the first one counts as one. - * - * One is also the answer for a call the detector had no room to remember, and for one carrying - * nothing to identify it. It is the only number that cannot make anything happen: a rule about - * repetition reads it as a first attempt and stands aside. A count nobody can substantiate must - * never be the reason a Bot is refused. - */ - count: number; - /** - * The call's identity, in a form a person can read. - * - * Null when nothing distinguished it. See `fingerprintOf`: a bare tool name is not a call worth - * counting, and the honest count for one of those is a single occurrence. - */ - fingerprint: string | null; - /** - * The threshold this call has just reached, or null on the overwhelming majority of calls. - * - * Reported once per run of repetition rather than on every call past it. A run ends when the - * window empties completely, and a key that fills it again afterwards reports again, because that - * is a Bot that got stuck twice. A count that merely dips and climbs is the same run still going - * and says nothing further: one incident, one row per threshold, or a row per wobble would be a - * row per attempt under another name. - */ - threshold: number | null; -}; - -export type RepeatDetector = { - /** Records the call and answers with what it now knows. Never throws, never blocks. */ - observe: (botId: string, call: RepeatedCall) => RepeatObservation; -}; - -export type RepeatDetectorOptions = { - windowMs?: number; - thresholds?: readonly number[]; - maxKeysPerBot?: number; - maxBots?: number; - /** Injected so a test can move time without waiting for it. */ - now?: () => number; -}; - -/** One key's history, held only while it is still inside the window. */ -type Occurrences = { - /** When each counted call happened, oldest first. */ - at: number[]; - /** Which thresholds this run of repetition has already reported. */ - reported: Set; -}; - -/** - * One Bot's history, and when it was last heard from. - * - * The time is kept here as well as inside the keys because a Bot that has stopped acting has to give - * its place up without anybody walking its whole map to work out that it has. - */ -type BotHistory = { - calls: Map; - lastSeen: number; -}; - -export function createRepeatDetector( - options: RepeatDetectorOptions = {}, -): RepeatDetector { - const windowMs = options.windowMs ?? DEFAULT_REPEAT_WINDOW_MS; - const maxKeysPerBot = options.maxKeysPerBot ?? DEFAULT_REPEAT_KEYS_PER_BOT; - const maxBots = options.maxBots ?? DEFAULT_REPEAT_BOTS; - // Ascending, so the loop below ends on the highest threshold a call crossed rather than on - // whichever one the caller happened to list last. - const thresholds = [ - ...(options.thresholds ?? DEFAULT_REPEAT_THRESHOLDS), - ].sort((a, b) => a - b); - const clock = options.now ?? Date.now; - - /** - * Per Bot, then per call. - * - * Nested rather than keyed on a combined string so that one Bot's varied work cannot push another - * Bot's history out: the cap is per Bot, and a shared map would make it a race between them. - * - * Both levels are capped, and neither ever drops something that is still inside the window. What - * is in here is therefore the recent past and nothing else, which is the only claim about memory - * this module can honestly make: an id it has never usefully counted cannot be made to sit here - * for the life of the process. - */ - const perBot = new Map(); - - return { - observe(botId, call) { - const fingerprint = fingerprintOf(call); - if (!fingerprint) { - return { count: 1, fingerprint: null, threshold: null }; - } - - const now = clock(); - const cutoff = now - windowMs; - - let history = perBot.get(botId); - if (!history) { - if (perBot.size >= maxBots) forgetQuietBots(perBot, cutoff); - if (perBot.size >= maxBots) return untracked(fingerprint); - history = { calls: new Map(), lastSeen: now }; - perBot.set(botId, history); - } - // Whether or not the call itself is counted. A Bot making calls is a Bot at work, and one that - // has filled its keys with a long loop must not lose the loop by looking idle. - history.lastSeen = now; - const calls = history.calls; - - let entry = calls.get(fingerprint); - if (!entry) { - if (calls.size >= maxKeysPerBot) forgetExpiredCalls(calls, cutoff); - if (calls.size >= maxKeysPerBot) return untracked(fingerprint); - entry = { at: [], reported: new Set() }; - calls.set(fingerprint, entry); - } - - trimToWindow(entry.at, cutoff); - if (entry.at.length === 0) { - // Nothing survived the window, so whatever run of repetition this key was in has ended and - // its thresholds are free to report again. Without this a Bot that got stuck, recovered and - // got stuck again an hour later would leave one row for two incidents. - entry.reported.clear(); - } - entry.at.push(now); - - const count = entry.at.length; - let threshold: number | null = null; - for (const candidate of thresholds) { - if (count >= candidate && !entry.reported.has(candidate)) { - entry.reported.add(candidate); - threshold = candidate; - } - } - - return { count, fingerprint, threshold }; - }, - }; -} - -/** - * What makes two calls the same call. - * - * The tool name is not enough, and a detector keyed on it alone would be worse than none: five - * clicks may be five different buttons, so a Bot working steadily down a form would look exactly - * like one stuck on its first field, and the first person to see a rule fire on that would turn the - * feature off. So the key is the tool plus the argument saying WHICH thing it acted on — the ref, - * the key pressed, the file path, the address being opened — and a call carrying none of those is - * not counted at all. - * - * Deliberately absent: the text being typed. A Bot that fills the same field thirty times is worth - * catching, but what it filled it with is a password as often as it is anything else, and from here - * the fingerprint travels into an audit row. The cost is that thirty different values into one field - * read as thirty repeats, which is the direction to be wrong in. - * - * A ref only means anything against the snapshot it came from. A page that re-renders and hands back - * a different ref for the same button reads as a different call, so a Bot looping around a reload is - * undercounted. Keying on the element's label instead would follow the button across snapshots and - * merge two buttons that share a label, and a count that is sometimes low is easier to live with - * than one that is sometimes about the wrong thing. - * - * Readable, because it goes on the audit row as-is. An investigator reading "the same action 25 - * times" needs to be told which action without going and decoding a hash. - */ -export function fingerprintOf(call: RepeatedCall): string | null { - const parts: string[] = []; - const add = (label: string, value: string | undefined) => { - const normalized = normalize(value); - if (normalized) parts.push(`${label}=${normalized}`); - }; - - add("ref", call.ref); - add("key", call.key); - add("file", call.filePath); - add("url", call.targetUrl); - - if (parts.length === 0) return null; - return [normalize(call.tool) || call.tool, ...parts].join(" "); -} - -/** - * Whitespace collapsed and trimmed. - * - * A model reproducing an argument from its own earlier output does not always reproduce the spacing, - * and a Bot writing to `reports/q3.md` and to `reports/q3.md ` is doing one thing twice. Treating - * those as two calls would let a stuck Bot slip the count without changing anything about what it - * was actually doing. - */ -function normalize(value: string | undefined): string { - return (value ?? "").replaceAll(/\s+/g, " ").trim(); -} - -/** - * What a call is worth when there was no room to remember it. - * - * The fingerprint is still returned, because it is a fact about the call and costs nothing to say. - * The count is one and the threshold is null, so nothing downstream acts on a number this module - * could not stand behind. - */ -function untracked(fingerprint: string): RepeatObservation { - return { count: 1, fingerprint, threshold: null }; -} - -/** - * Timestamps outside the window, dropped in place. - * - * Oldest first, so the scan stops at the first survivor and costs what it actually removes rather - * than the length of the list. Rebuilding the list instead would make a Bot hammering one call pay - * for its own history on every attempt, which is the Bot this module exists to describe. - */ -function trimToWindow(at: number[], cutoff: number) { - const surviving = at.findIndex((time) => time > cutoff); - if (surviving === -1) { - at.length = 0; - return; - } - if (surviving > 0) at.splice(0, surviving); -} - -/** - * Keys whose last call has aged out, which are the only ones safe to forget. - * - * They carry no count any more: the next call on one of them would start from one whether it was - * held or not, so dropping it loses nothing and frees the place for a call that might. - */ -function forgetExpiredCalls(calls: Map, cutoff: number) { - for (const [key, entry] of calls) { - if ((entry.at.at(-1) ?? 0) <= cutoff) calls.delete(key); - } -} - -/** The same rule one level up: a Bot that has not acted for a whole window has nothing left to say. */ -function forgetQuietBots(perBot: Map, cutoff: number) { - for (const [botId, history] of perBot) { - if (history.lastSeen <= cutoff) perBot.delete(botId); - } -} diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index f468e48..3ce1c28 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -13,7 +13,6 @@ import { } from "./client"; import { type ActionActor, - ActionNeedsApprovalError, ActionRefusedError, type ComputerGateway, } from "./gateway"; @@ -63,7 +62,6 @@ 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); @@ -81,13 +79,9 @@ 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); } @@ -114,23 +108,12 @@ 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, - asApprovalId(body), - ); + return gateway.click(botId, botId, actor, ref, signal); }), ); @@ -151,7 +134,6 @@ export function createComputerRoutes( submit: body?.submit === true, }, signal, - asApprovalId(body), ); }), ); @@ -171,22 +153,15 @@ 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 } : {}), - }, - asApprovalId(body), - ), + gateway.scroll(botId, botId, actor, { + ...(typeof body?.deltaY === "number" ? { deltaY: body.deltaY } : {}), + }), ), ); @@ -324,17 +299,11 @@ 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() } - : {}), - }, - asApprovalId(body), - ), + gateway.listFiles(botId, botId, actor, { + ...(typeof body?.path === "string" && body.path.trim() + ? { path: body.path.trim() } + : {}), + }), ), ); @@ -343,13 +312,7 @@ 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() }, - asApprovalId(body), - ); + return gateway.readFile(botId, botId, actor, { path: body.path.trim() }); }), ); @@ -361,17 +324,11 @@ 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, - }, - asApprovalId(body), - ); + return gateway.writeFile(botId, botId, actor, { + path: body.path.trim(), + contents: body.contents, + append: body.append === true, + }); }), ); @@ -483,9 +440,6 @@ 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) { @@ -523,44 +477,6 @@ 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/config.ts b/server/src/config.ts index 18ad61f..9e9c2c8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -82,15 +82,6 @@ export type DeploymentConfig = { * precedence, which is the only subtle thing about them, impossible to see. */ policy?: ActionPolicy; - /** - * How long two identical calls count as the same repetition, in milliseconds. - * - * Absent uses the built-in window, which assumes a retry loop is a model round trip apart. It is - * here because that assumption is about someone else's model: a deployment on a slow or heavily - * queued provider can have genuine retries minutes apart, and there the built-in window counts - * every attempt as the first one and a rule about repetition never fires at all. - */ - repeatWindowMs?: number; }; }; @@ -287,13 +278,11 @@ function computerConfig( const computerToken = optional(environment, "COMPUTER_TOKEN"); const supervisorUrl = url(environment, "COMPUTER_SUPERVISOR_URL"); const supervisorToken = optional(environment, "SUPERVISOR_TOKEN"); - const repeatWindowMs = milliseconds(environment, "COMPUTER_REPEAT_WINDOW_MS"); return { baseUrl, allowPrivateHosts: optional(environment, "AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS") === "true", ...(policy ? { policy } : {}), - ...(repeatWindowMs ? { repeatWindowMs } : {}), ...(computerToken ? { token: computerToken } : {}), ...(supervisorUrl ? { @@ -306,29 +295,6 @@ function computerConfig( }; } -/** - * A duration in milliseconds, or a refusal to start. - * - * Refused rather than quietly defaulted, for the same reason a malformed policy is. An operator who - * widened a window and typed `3m` would otherwise get a running deployment on the built-in value, - * and the only evidence would be a rule that never fires, which reads exactly like a Bot behaving - * itself. - */ -function milliseconds( - environment: Environment, - name: string, -): number | undefined { - const raw = optional(environment, name); - if (!raw) { - return undefined; - } - const value = Number(raw); - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive whole number of milliseconds`); - } - return value; -} - /** * The action policy, as JSON in one variable. * diff --git a/server/src/db/schema/computer.ts b/server/src/db/schema/computer.ts index 231f9d1..517384e 100644 --- a/server/src/db/schema/computer.ts +++ b/server/src/db/schema/computer.ts @@ -26,15 +26,6 @@ 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 695d98f..3fc067a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,14 +17,12 @@ 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 { createPolicyStore, DEFAULT_ACTION_POLICY, } from "./computer/policy-store"; -import { createRepeatDetector } from "./computer/repeat"; import { createSupervisorClient } from "./computer/supervisor"; import { loadConfig } from "./config"; import { createConnectorAdminService } from "./connectors"; @@ -198,23 +196,12 @@ 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, { @@ -356,18 +343,8 @@ 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 - // assumes. Otherwise the gateway makes its own and nobody has to know it exists. - ...(config.computer?.repeatWindowMs - ? { - repeat: createRepeatDetector({ - windowMs: config.computer.repeatWindowMs, - }), - } - : {}), }) : undefined, policyStore, @@ -386,8 +363,6 @@ 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 bfa5348..59c6c2a 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -6,7 +6,6 @@ import { CATALOGUE } from "./catalogue"; import { CatalogueEntryUnknownError, CustomServerRefusedError, - PluginNeedsApprovalError, PluginRefusedError, type PluginStore, } from "./store"; @@ -349,7 +348,6 @@ 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); @@ -361,36 +359,9 @@ 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 9470678..b4caf39 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1,10 +1,5 @@ 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, @@ -122,33 +117,6 @@ 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.`); @@ -191,15 +159,6 @@ 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) { @@ -253,74 +212,6 @@ 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. @@ -845,14 +736,6 @@ 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("/"); @@ -906,29 +789,18 @@ 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, 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 ." + * 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`. */ const context: PolicyContext = { tool: { name: toolNameFor(input.ref) }, bot: { id: input.botId }, actor: { id: input.actorId }, page: { url: "", host: "" }, - // One, for the same reason as the empty strings, and with a cost worth naming: repetition is - // counted by the computer gateway, and nothing counts a Bot calling the same MCP tool over - // and over. A rule about repetition is therefore false here rather than unevaluable, which - // keeps a browser rule from refusing every tool call, and leaves a Bot looping through - // somebody else's server as a gap this deployment cannot yet see. - 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 }, @@ -936,43 +808,8 @@ 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: carriedOut ? "mcp.call_succeeded" : "mcp.call_rejected", + eventType: verdict.forward ? "mcp.call_succeeded" : "mcp.call_rejected", targetType: "mcp_tool", targetId: input.ref, payload: { @@ -982,17 +819,16 @@ export function createPluginStore(options: PluginStoreOptions) { tool: toolName, effect, decision: { - allowed: verdict.allowed || approved !== undefined, + allowed: verdict.allowed, mode: verdict.mode, rule: verdict.matched, source: verdict.source, - carriedOut, - ...(approved ? { approvedBy: approved } : {}), + carriedOut: verdict.forward, }, }, }); - if (!carriedOut) { + if (!verdict.forward) { throw new PluginRefusedError(verdict.reason, verdict.matched); } diff --git a/server/tests/approval-routes.test.ts b/server/tests/approval-routes.test.ts deleted file mode 100644 index 94c4d39..0000000 --- a/server/tests/approval-routes.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -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 deleted file mode 100644 index 7ecfe1f..0000000 --- a/server/tests/computer-approvals.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -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 6de1937..eff68c0 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -1,17 +1,11 @@ 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"; import type { ActionPolicy } from "../src/computer/policy"; -import { - createRepeatDetector, - type RepeatDetector, -} from "../src/computer/repeat"; import type { SnapshotResult } from "../src/computer/schema"; /** @@ -107,36 +101,19 @@ function fakeAudit() { } const ACTOR = { id: "dev-local-user" }; -/** 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"], -}; +const PERMISSIVE: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; -async function gatewayWith( - policy: ActionPolicy | undefined, - /** Only the repetition tests supply one; everything else gets the gateway's own. */ - repeat?: RepeatDetector, -) { +async function gatewayWith(policy: ActionPolicy | undefined) { 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, approvals, calls, rows }; + return { gateway, calls, rows }; } describe("the computer gateway", () => { @@ -246,7 +223,6 @@ describe("the computer gateway", () => { const { gateway, calls, rows } = await gatewayWith({ mode: "dry-run", deny: ['contains(element.name, "submit")'], - ask: [], allow: ["true"], }); @@ -421,470 +397,3 @@ describe("the computer gateway", () => { expect(rows[0]?.payload.element).toBe("not in the current snapshot"); }); }); - -/** - * 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. - * - * Every governed action already passes through one function that already writes a row for it, so - * counting is very nearly free here and impossible anywhere else. The part that matters is where the - * count goes: into the policy context, before the decision, so a deployment can act on it rather than - * read about it a week later. - */ -describe("a Bot going in circles", () => { - const click = { ref: "e9", snapshotId: 7 }; - - test("the count reaches the policy, and the rule refuses the attempt that crosses the line", async () => { - const { gateway, calls, rows } = await gatewayWith({ - ...PERMISSIVE, - deny: ["repeat.count >= 3"], - }); - - await gateway.click("default", "bot-1", ACTOR, click); - await gateway.click("default", "bot-1", ACTOR, click); - // The first two are the same action on the same button and nothing about them is objectionable. - expect(calls).toEqual(["click", "click"]); - - await expect( - gateway.click("default", "bot-1", ACTOR, click), - ).rejects.toThrow(ActionRefusedError); - // Counted before the decision, so the third attempt is the one refused rather than the fourth. - expect(calls).toEqual(["click", "click"]); - expect(rows.at(-1)?.eventType).toBe("computer.action_refused"); - }); - - test("a rule about repetition leaves a Bot doing varied work alone", async () => { - // The failure that would take this feature back out again: a Bot working steadily down a form - // refused for doing its job. - const { gateway, calls } = await gatewayWith({ - ...PERMISSIVE, - deny: ["repeat.count >= 3"], - }); - - await gateway.click("default", "bot-1", ACTOR, { - ref: "e1", - snapshotId: 7, - }); - await gateway.click("default", "bot-1", ACTOR, { - ref: "e9", - snapshotId: 7, - }); - await gateway.type("default", "bot-1", ACTOR, { - ref: "e1", - snapshotId: 7, - text: "Grace Hopper", - }); - - expect(calls).toEqual(["click", "click", "type"]); - }); - - test("crossing a threshold writes its own row, ahead of the decision it explains", async () => { - const { gateway, rows } = await gatewayWith( - PERMISSIVE, - createRepeatDetector({ thresholds: [3] }), - ); - - await gateway.click("default", "bot-1", ACTOR, click); - await gateway.click("default", "bot-1", ACTOR, click); - await gateway.click("default", "bot-1", ACTOR, click); - - // Two allowed actions, then the observation, then the third allowed action. Filed the other way - // round a reader has to deduce the cause from a row written after its effect. - expect(rows.map((row) => row.eventType)).toEqual([ - "computer.action_allowed", - "computer.action_allowed", - "computer.action_repeated", - "computer.action_allowed", - ]); - }); - - test("the row says which call, and how many times", async () => { - const { gateway, rows } = await gatewayWith( - PERMISSIVE, - createRepeatDetector({ thresholds: [2] }), - ); - - await gateway.click("default", "bot-1", ACTOR, click); - await gateway.click("default", "bot-1", ACTOR, click); - - const repeated = rows.find( - (row) => row.eventType === "computer.action_repeated", - ); - expect(repeated?.payload).toMatchObject({ - action: "computer_click", - bot: "bot-1", - fingerprint: "computer_click ref=e9", - count: 2, - }); - // Not a refusal, and it must not carry the furniture of one. A row with a `decision` block would - // read as the policy having answered a question nobody asked it. - expect(repeated?.payload.decision).toBeUndefined(); - }); - - test("a refused action is still counted, because the Bot still tried", async () => { - // A Bot hammering a button the policy forbids is going in circles as surely as one hammering a - // button that works, and it is the case an operator most wants to see. - const { gateway, rows } = await gatewayWith( - { ...PERMISSIVE, deny: ['contains(element.name, "submit")'] }, - createRepeatDetector({ thresholds: [2] }), - ); - - await gateway.click("default", "bot-1", ACTOR, click).catch(() => {}); - await gateway.click("default", "bot-1", ACTOR, click).catch(() => {}); - - expect(rows.map((row) => row.eventType)).toEqual([ - "computer.action_refused", - "computer.action_repeated", - "computer.action_refused", - ]); - }); - - test("two Bots on one gateway do not pool a count", async () => { - const { gateway, rows } = await gatewayWith( - PERMISSIVE, - createRepeatDetector({ thresholds: [2] }), - ); - - await gateway.click("default", "sales-bot", ACTOR, click); - await gateway.click("default", "research-bot", ACTOR, click); - - // One click each. A pooled count would file this against whichever Bot happened to go second. - expect( - rows.some((row) => row.eventType === "computer.action_repeated"), - ).toBe(false); - }); - - test("a call with nothing to distinguish it is never reported as a repeat", async () => { - // Scrolling names no element. Counting it by tool name alone would report a Bot reaching the - // bottom of a long page as one going in circles. - const { gateway, rows } = await gatewayWith( - PERMISSIVE, - createRepeatDetector({ thresholds: [2] }), - ); - - await gateway.scroll("default", "bot-1", ACTOR, { direction: "down" }); - await gateway.scroll("default", "bot-1", ACTOR, { direction: "down" }); - await gateway.scroll("default", "bot-1", ACTOR, { direction: "down" }); - - expect( - rows.every((row) => row.eventType === "computer.action_allowed"), - ).toBe(true); - }); - - test("a lost observation row does not refuse an action the policy allows", async () => { - // The detector observes and the policy decides, and that has to survive the audit store having a - // bad moment. Letting the observation row throw would refuse every third, tenth and twenty-fifth - // identical call, before the policy had been asked, on an action nothing objected to. - const { client, calls } = fakeClient(); - const rows: AuditEventInput[] = []; - const store: AuditStore = { - insert: async (event) => { - if (event.eventType === "computer.action_repeated") { - throw new Error("the audit store is unreachable"); - } - rows.push(event); - }, - }; - const gateway = createComputerGateway({ - client, - auditStore: store, - policy: () => PERMISSIVE, - repeat: createRepeatDetector({ thresholds: [2] }), - }); - await gateway.snapshot("default"); - - await gateway.click("default", "bot-1", ACTOR, click); - await gateway.click("default", "bot-1", ACTOR, click); - - // Both clicks happened and both decisions are on the record. What was lost is the note saying - // they were the same click twice, which is the only thing that may be lost here. - expect(calls).toEqual(["click", "click"]); - expect(rows.map((row) => row.eventType)).toEqual([ - "computer.action_allowed", - "computer.action_allowed", - ]); - }); - - test("a repeated file write names the path and not the browser's page", async () => { - // The workspace has nothing to do with whatever the browser happens to be showing, and naming a - // host on that row sends a reader somewhere irrelevant. - const { gateway, rows } = await gatewayWith( - PERMISSIVE, - createRepeatDetector({ thresholds: [2] }), - ); - - await gateway.writeFile("default", "bot-1", ACTOR, { - path: "notes.md", - contents: "again", - }); - await gateway.writeFile("default", "bot-1", ACTOR, { - path: "notes.md", - contents: "again", - }); - - const repeated = rows.find( - (row) => row.eventType === "computer.action_repeated", - ); - expect(repeated?.payload.fingerprint).toBe( - "computer_write_file file=notes.md", - ); - expect(repeated?.payload.page).toBeUndefined(); - }); -}); diff --git a/server/tests/computer-policy.test.ts b/server/tests/computer-policy.test.ts index d9bb383..5191793 100644 --- a/server/tests/computer-policy.test.ts +++ b/server/tests/computer-policy.test.ts @@ -21,21 +21,12 @@ function context(overrides: Partial = {}): PolicyContext { bot: { id: "risk-analyst" }, actor: { id: "dev-local-user" }, page: { url: "https://example.com/order", host: "example.com" }, - // The first time this Bot has made this call, which is what a context with nothing to say about - // repetition means. Always present: an absent field throws inside CEL, and a throwing deny rule - // denies, so a rule about repetition would otherwise refuse everything. - repeat: { count: 1 }, element: { ref: "e13", role: "button", name: "Submit order" }, ...overrides, }; } -const permissive: ActionPolicy = { - mode: "enforce", - deny: [], - ask: [], - allow: ["true"], -}; +const permissive: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; describe("evaluateActionPolicy", () => { test("an absent policy refuses, rather than permitting everything", () => { @@ -47,7 +38,7 @@ describe("evaluateActionPolicy", () => { test("an empty allow list refuses", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: [], ask: [], allow: [] }, + { mode: "enforce", deny: [], allow: [] }, context(), ); expect(decision.allowed).toBe(false); @@ -98,7 +89,7 @@ describe("evaluateActionPolicy", () => { test("a broken allow expression does not permit", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: [], ask: [], allow: ["also not ( valid"] }, + { mode: "enforce", deny: [], allow: ["also not ( valid"] }, context(), ); expect(decision.allowed).toBe(false); @@ -110,7 +101,6 @@ describe("evaluateActionPolicy", () => { { mode: "dry-run", deny: ['contains(element.name, "submit")'], - ask: [], allow: ["true"], }, context(), @@ -170,43 +160,10 @@ 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: [] }], @@ -227,7 +184,6 @@ describe("the second door", () => { const policy = { mode: "enforce" as const, deny: ['tool.name == "computer_key" && key == "Enter"'], - ask: [], allow: ["true"], }; const refused = evaluateActionPolicy(policy, { @@ -235,7 +191,6 @@ describe("the second door", () => { bot: { id: "sales" }, actor: { id: "someone" }, page: { url: "https://example.com/order", host: "example.com" }, - repeat: { count: 1 }, key: "Enter", }); expect(refused.allowed).toBe(false); @@ -246,50 +201,10 @@ describe("the second door", () => { bot: { id: "sales" }, actor: { id: "someone" }, page: { url: "https://example.com/order", host: "example.com" }, - repeat: { count: 1 }, key: "a", }); 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); - }); }); /** @@ -302,7 +217,6 @@ describe("a rule written about what an action does", () => { bot: { id: "b" }, actor: { id: "a" }, page: { url: "https://example.com/", host: "example.com" }, - repeat: { count: 1 }, intent: "activate", ...extra, }); @@ -310,7 +224,6 @@ 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"], }; @@ -394,13 +307,12 @@ describe("a rule that names an identifier only some actions carry", () => { bot: { id: "b" }, actor: { id: "a" }, page: { url: "https://httpbin.org/forms/post", host: "httpbin.org" }, - repeat: { count: 1 }, intent: "navigate", }; test("unguarded, it refuses a navigation that has no key at all", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: ['key == "Enter"'], ask: [], allow: ["true"] }, + { mode: "enforce", deny: ['key == "Enter"'], allow: ["true"] }, navigating, ); // Failing closed on an unevaluable rule is the safe answer. The shipped preset carries the guard @@ -413,7 +325,6 @@ describe("a rule that names an identifier only some actions carry", () => { { mode: "enforce", deny: ['tool.name == "computer_key" && key == "Enter"'], - ask: [], allow: ["true"], }, navigating, @@ -426,7 +337,6 @@ describe("a rule that names an identifier only some actions carry", () => { { mode: "enforce", deny: ['tool.name == "computer_key" && key == "Enter"'], - ask: [], allow: ["true"], }, { @@ -440,213 +350,3 @@ describe("a rule that names an identifier only some actions carry", () => { expect(decision.allowed).toBe(false); }); }); - -/** - * 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. - * - * Both are the same action on the same element, and any rule able to refuse the thirtieth by its - * shape would refuse the first as well. So the count is the whole of it, and these check that a rule - * written against it actually evaluates: `repeat` is a nested field like `page` and `element`, and a - * rule the engine cannot evaluate denies, which would turn one restriction into a Bot that can do - * nothing at all. - */ -describe("a rule about a Bot repeating itself", () => { - const repeating: ActionPolicy = { - mode: "enforce", - deny: ["repeat.count >= 10"], - ask: [], - allow: ["true"], - }; - - test("leaves the attempts below the line alone", () => { - expect( - evaluateActionPolicy(repeating, context({ repeat: { count: 9 } })) - .allowed, - ).toBe(true); - }); - - test("refuses the attempt that crosses it, not the one after", () => { - const decision = evaluateActionPolicy( - repeating, - context({ repeat: { count: 10 } }), - ); - expect(decision.allowed).toBe(false); - expect(decision.matched).toBe("repeat.count >= 10"); - }); - - test("goes on refusing past it", () => { - expect( - evaluateActionPolicy(repeating, context({ repeat: { count: 40 } })) - .allowed, - ).toBe(false); - }); -}); diff --git a/server/tests/computer-repeat.test.ts b/server/tests/computer-repeat.test.ts deleted file mode 100644 index f45b545..0000000 --- a/server/tests/computer-repeat.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - createRepeatDetector, - DEFAULT_REPEAT_WINDOW_MS, - fingerprintOf, -} from "../src/computer/repeat"; - -/** - * What the detector must get right, and every one of them is a way of being wrong that looks fine. - * - * A detector that overcounts is worse than none: the first person to see a boundary refuse a Bot - * working steadily down a form turns the boundary off, and takes the real refusals with it. A - * detector that fires its thresholds again on every call past them buries the actions a Bot took - * under the observation that it kept taking them. And a detector two Bots share reports one Bot's - * loop against another Bot's name, which is the one thing an audit trail may never do. - * - * Time is injected. A test that waits three minutes to prove a window expires is a test somebody - * eventually deletes. - */ - -/** A clock a test drives by hand, so a window can expire in a microsecond. */ -function clock(start = 1_000_000) { - let time = start; - return { - now: () => time, - advance(ms: number) { - time += ms; - }, - }; -} - -describe("counting a Bot repeating itself", () => { - test("identical calls count up", () => { - const detector = createRepeatDetector({ now: clock().now }); - const call = { tool: "computer_click", ref: "e9" }; - - expect(detector.observe("sales-bot", call).count).toBe(1); - expect(detector.observe("sales-bot", call).count).toBe(2); - expect(detector.observe("sales-bot", call).count).toBe(3); - }); - - test("the count includes the call being observed", () => { - // The gateway counts before it asks the policy, so a rule saying `repeat.count >= 10` has to - // refuse the tenth attempt. Starting at zero would make it refuse the eleventh, and nobody would - // notice until they were counting rows in an incident. - const detector = createRepeatDetector({ now: clock().now }); - expect( - detector.observe("sales-bot", { tool: "computer_click", ref: "e1" }), - ).toMatchObject({ count: 1 }); - }); - - test("a different argument is a different call", () => { - const detector = createRepeatDetector({ now: clock().now }); - - detector.observe("sales-bot", { tool: "computer_click", ref: "e1" }); - detector.observe("sales-bot", { tool: "computer_click", ref: "e2" }); - const third = detector.observe("sales-bot", { - tool: "computer_click", - ref: "e3", - }); - - // Three clicks, three buttons. A Bot working down a form must not look like one stuck on its - // first field, or the feature gets switched off the first day somebody uses it. - expect(third.count).toBe(1); - }); - - test("a different tool on the same argument is a different call", () => { - const detector = createRepeatDetector({ now: clock().now }); - - detector.observe("sales-bot", { - tool: "computer_read_file", - filePath: "a", - }); - const written = detector.observe("sales-bot", { - tool: "computer_write_file", - filePath: "a", - }); - - expect(written.count).toBe(1); - }); - - test("whitespace around an argument does not buy a Bot a fresh count", () => { - // A model reproducing a path from its own earlier output does not always reproduce the spacing, - // and it is doing the same thing either way. - const detector = createRepeatDetector({ now: clock().now }); - - detector.observe("bot", { tool: "computer_write_file", filePath: "q3.md" }); - const spaced = detector.observe("bot", { - tool: "computer_write_file", - filePath: " q3.md ", - }); - - expect(spaced.count).toBe(2); - }); - - test("a call with nothing to distinguish it is not counted", () => { - // Scrolling is the only governed call that names nothing. Counting it by tool name alone would - // mean a Bot reading a long page trips a rule about repetition just by reaching the bottom. - const detector = createRepeatDetector({ now: clock().now }); - - for (let attempt = 0; attempt < 30; attempt++) { - const seen = detector.observe("bot", { tool: "computer_scroll" }); - expect(seen.count).toBe(1); - expect(seen.fingerprint).toBeNull(); - expect(seen.threshold).toBeNull(); - } - }); - - test("the window expires, and the count starts again", () => { - const time = clock(); - const detector = createRepeatDetector({ - now: time.now, - windowMs: 60_000, - }); - const call = { - tool: "computer_navigate", - targetUrl: "https://example.com", - }; - - detector.observe("bot", call); - time.advance(30_000); - expect(detector.observe("bot", call).count).toBe(2); - - // Far enough that the first two fall out of the window entirely. - time.advance(61_000); - expect(detector.observe("bot", call).count).toBe(1); - }); - - test("the window slides rather than resetting on a fixed tick", () => { - // A Bot pacing itself just inside the window still accumulates, which is the point: the window - // is about how close together attempts are, not about which minute they landed in. - const time = clock(); - const detector = createRepeatDetector({ now: time.now, windowMs: 60_000 }); - const call = { tool: "computer_click", ref: "e9" }; - - detector.observe("bot", call); - time.advance(50_000); - detector.observe("bot", call); - time.advance(50_000); - - // The first has aged out, the second has not. - expect(detector.observe("bot", call).count).toBe(2); - }); - - test("each threshold fires exactly once", () => { - const detector = createRepeatDetector({ - now: clock().now, - thresholds: [3, 10], - }); - const call = { tool: "computer_click", ref: "e9" }; - - const fired: number[] = []; - for (let attempt = 0; attempt < 15; attempt++) { - const seen = detector.observe("bot", call); - if (seen.threshold !== null) fired.push(seen.threshold); - } - - // A row per attempt past the line would bury the attempts themselves under the observation that - // they kept happening. - expect(fired).toEqual([3, 10]); - }); - - test("a threshold fires on the attempt that reaches it", () => { - const detector = createRepeatDetector({ - now: clock().now, - thresholds: [3], - }); - const call = { tool: "computer_click", ref: "e9" }; - - expect(detector.observe("bot", call).threshold).toBeNull(); - expect(detector.observe("bot", call).threshold).toBeNull(); - expect(detector.observe("bot", call)).toMatchObject({ - count: 3, - threshold: 3, - }); - }); - - test("a Bot that gets stuck twice is reported twice", () => { - // Once the window has emptied the run of repetition is over, so the next one is a new incident - // and deserves its own row. Holding the thresholds for the life of the process would leave one - // row for an afternoon of separate failures. - const time = clock(); - const detector = createRepeatDetector({ - now: time.now, - windowMs: 60_000, - thresholds: [3], - }); - const call = { tool: "computer_click", ref: "e9" }; - - detector.observe("bot", call); - detector.observe("bot", call); - expect(detector.observe("bot", call).threshold).toBe(3); - - time.advance(120_000); - detector.observe("bot", call); - detector.observe("bot", call); - expect(detector.observe("bot", call).threshold).toBe(3); - }); - - test("a run that dips without emptying is still the same run", () => { - // The run ends when the window is empty, not when the count falls back under a threshold. A row - // every time a stuck Bot's count wobbles past the line would be a row per attempt under another - // name, which is the thing the thresholds exist to avoid. - const time = clock(); - const detector = createRepeatDetector({ - now: time.now, - windowMs: 60_000, - thresholds: [2], - }); - const call = { tool: "computer_click", ref: "e9" }; - - detector.observe("bot", call); - time.advance(10_000); - expect(detector.observe("bot", call).threshold).toBe(2); - - // The first attempt ages out and the second does not, so the count falls to one and climbs - // straight back. The key never went quiet, so this is the same incident, already reported. - time.advance(55_000); - expect(detector.observe("bot", call)).toMatchObject({ - count: 2, - threshold: null, - }); - }); - - test("two Bots do not share a count", () => { - // The audit row names a Bot. A count pooled across Bots would report one Bot's loop against - // another Bot's name, which is the one thing a trail may never do. - const detector = createRepeatDetector({ now: clock().now }); - const call = { tool: "computer_click", ref: "e9" }; - - detector.observe("sales-bot", call); - detector.observe("sales-bot", call); - detector.observe("sales-bot", call); - - expect(detector.observe("research-bot", call).count).toBe(1); - expect(detector.observe("sales-bot", call).count).toBe(4); - }); - - test("the number of calls held per Bot is capped", () => { - // A Bot doing genuinely varied work never repeats anything, so every call it makes is a new key. - // Without a cap the map grows for exactly the Bots this has nothing to say about. - const detector = createRepeatDetector({ - now: clock().now, - maxKeysPerBot: 3, - }); - - detector.observe("bot", { tool: "computer_click", ref: "e1" }); - detector.observe("bot", { tool: "computer_click", ref: "e2" }); - detector.observe("bot", { tool: "computer_click", ref: "e3" }); - - // Full, so the fourth call is not counted. Nothing still inside the window is thrown out to make - // room for it, and a call nobody could count reports one, which no rule acts on. - expect( - detector.observe("bot", { tool: "computer_click", ref: "e4" }).count, - ).toBe(1); - expect( - detector.observe("bot", { tool: "computer_click", ref: "e4" }).count, - ).toBe(1); - // The half of the cap that matters: a live loop survives a Bot doing other things in between, - // however much of it there is. - expect( - detector.observe("bot", { tool: "computer_click", ref: "e1" }).count, - ).toBe(2); - }); - - test("a Bot going round a loop wider than the cap is still counted", () => { - // The failure that would make the whole feature ornamental. Dropping the least recently seen key - // to make room drops each key exactly one step before it comes round again, so a Bot circling - // all afternoon would report every call as its first and no rule about repetition would ever - // fire on the one thing this exists to catch. - const detector = createRepeatDetector({ - now: clock().now, - maxKeysPerBot: 3, - thresholds: [3], - }); - - const counts: number[] = []; - const fired: number[] = []; - for (let round = 0; round < 3; round++) { - for (const ref of ["e1", "e2", "e3", "e4"]) { - const seen = detector.observe("bot", { tool: "computer_click", ref }); - counts.push(seen.count); - if (seen.threshold !== null) fired.push(seen.threshold); - } - } - - // Three of the four keys fitted, and three times round is what the trail is told about. - expect(Math.max(...counts)).toBe(3); - expect(fired).toEqual([3, 3, 3]); - }); - - test("a call the cap turned away is counted once the window drains", () => { - // The cost of not evicting is a blind spot, and this is the thing that makes it bearable: it - // ends by itself within a window rather than lasting as long as the Bot does. - const time = clock(); - const detector = createRepeatDetector({ - now: time.now, - windowMs: 60_000, - maxKeysPerBot: 2, - }); - - detector.observe("bot", { tool: "computer_click", ref: "e1" }); - detector.observe("bot", { tool: "computer_click", ref: "e2" }); - expect( - detector.observe("bot", { tool: "computer_click", ref: "e3" }).count, - ).toBe(1); - - time.advance(61_000); - detector.observe("bot", { tool: "computer_click", ref: "e3" }); - expect( - detector.observe("bot", { tool: "computer_click", ref: "e3" }).count, - ).toBe(2); - }); - - test("the number of Bots held is capped", () => { - // The id counted against is the one in the request path, checked against a session and against - // nothing else. A caller naming a fresh Bot on every request would otherwise buy a map of its - // own each time, for the life of the process. - const detector = createRepeatDetector({ now: clock().now, maxBots: 2 }); - const call = { tool: "computer_click", ref: "e9" }; - - detector.observe("sales-bot", call); - detector.observe("research-bot", call); - for (let invented = 0; invented < 100; invented++) { - detector.observe(`bot-${invented}`, call); - } - - // The two that were really working keep their counts, and the hundred invented ones bought - // nothing at all. - expect(detector.observe("sales-bot", call).count).toBe(2); - expect(detector.observe("research-bot", call).count).toBe(2); - }); - - test("a Bot that has gone quiet gives its place up", () => { - const time = clock(); - const detector = createRepeatDetector({ - now: time.now, - windowMs: 60_000, - maxBots: 1, - }); - const call = { tool: "computer_click", ref: "e9" }; - - detector.observe("morning-bot", call); - // Still working, so it keeps its place and the second Bot is not counted. - expect(detector.observe("afternoon-bot", call).count).toBe(1); - - time.advance(61_000); - detector.observe("afternoon-bot", call); - expect(detector.observe("afternoon-bot", call).count).toBe(2); - }); - - test("one Bot's varied work does not evict another Bot's loop", () => { - const detector = createRepeatDetector({ - now: clock().now, - maxKeysPerBot: 2, - }); - const call = { tool: "computer_click", ref: "e9" }; - - detector.observe("stuck-bot", call); - for (let ref = 0; ref < 20; ref++) { - detector.observe("busy-bot", { tool: "computer_click", ref: `e${ref}` }); - } - - expect(detector.observe("stuck-bot", call).count).toBe(2); - }); - - test("the default window is a few minutes, not a few seconds", () => { - // A retry loop is a model round trip each time round. A window of seconds would count nothing at - // all, and the feature would look like it worked because it never fired. - expect(DEFAULT_REPEAT_WINDOW_MS).toBeGreaterThanOrEqual(60_000); - expect(DEFAULT_REPEAT_WINDOW_MS).toBeLessThanOrEqual(10 * 60_000); - }); -}); - -describe("the fingerprint an audit row carries", () => { - test("names the tool and the argument, in words", () => { - // It goes onto the row as written. An investigator reading "the same call, 25 times" has to be - // told which call without going and decoding anything. - expect(fingerprintOf({ tool: "computer_click", ref: "e9" })).toBe( - "computer_click ref=e9", - ); - expect( - fingerprintOf({ tool: "computer_write_file", filePath: "notes.md" }), - ).toBe("computer_write_file file=notes.md"); - expect( - fingerprintOf({ tool: "computer_key", ref: "e1", key: "Enter" }), - ).toBe("computer_key ref=e1 key=Enter"); - }); - - test("is null when the call named nothing", () => { - expect(fingerprintOf({ tool: "computer_scroll" })).toBeNull(); - }); -}); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index d784e5d..326ad5c 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -186,37 +186,4 @@ describe("deployment configuration", () => { expect(attempt).toThrow("AGENT_STALL_TIMEOUT_MS"); }, ); - - test("takes a widened repetition window, and leaves it absent when nobody set one", () => { - expect( - loadConfig({ - ...baseEnvironment, - AGENT_COMPUTER_URL: "http://localhost:4100", - COMPUTER_REPEAT_WINDOW_MS: "600000", - }).computer?.repeatWindowMs, - ).toBe(600_000); - - expect( - loadConfig({ - ...baseEnvironment, - AGENT_COMPUTER_URL: "http://localhost:4100", - }).computer?.repeatWindowMs, - ).toBeUndefined(); - }); - - // Refused rather than quietly defaulted, like a malformed policy. An operator who typed `3m` would - // otherwise get a deployment running the built-in window, and the only evidence would be a rule - // about repetition that never fires, which reads exactly like a Bot behaving itself. - test.each(["3m", "0", "-1", "180000.5"])( - "refuses to start on a repetition window of %p", - (value) => { - expect(() => - loadConfig({ - ...baseEnvironment, - AGENT_COMPUTER_URL: "http://localhost:4100", - COMPUTER_REPEAT_WINDOW_MS: value, - }), - ).toThrow("COMPUTER_REPEAT_WINDOW_MS"); - }, - ); }); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 38ff4a6..3349ab8 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2,7 +2,6 @@ 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"; @@ -13,11 +12,7 @@ import { mcpTools, pluginGrants, } from "../src/db/schema"; -import { - createPluginStore, - PluginNeedsApprovalError, - PluginRefusedError, -} from "../src/plugins/store"; +import { createPluginStore, PluginRefusedError } from "../src/plugins/store"; /** * The two questions a tool call has to pass, and the row each answer leaves behind. @@ -52,9 +47,6 @@ 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), @@ -64,7 +56,6 @@ const store = createPluginStore({ }, encryptionKey: "x".repeat(44), policy: () => policy, - approvals, }); async function auditRowsFor(targetId: string) { @@ -288,131 +279,6 @@ 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 e385963..d767297 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], ask: [], allow: ["true"] }, + { mode: "enforce", deny: [rule], allow: ["true"] }, "admin@example.test", ); @@ -47,24 +47,6 @@ 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"); @@ -73,12 +55,7 @@ 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], - ask: [], - allow: ["true"], - }); + await store.set({ mode: "enforce", deny: [rule], allow: ["true"] }); await store.reset(); // The saved row is removed rather than overwritten, so changing what configuration says then @@ -90,18 +67,8 @@ 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"], - ask: [], - allow: ["true"], - }); - await store.set({ - mode: "dry-run", - deny: ["second"], - ask: [], - allow: ["true"], - }); + await store.set({ mode: "enforce", deny: ["first"], allow: ["true"] }); + await store.set({ mode: "dry-run", deny: ["second"], allow: ["true"] }); const rows = await database.select().from(actionPolicy); // One boundary per deployment, by construction. Two rows would mean something has to choose. @@ -113,7 +80,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], ask: [], allow: ["true"] }, + { mode: "enforce", deny: [rule], allow: ["true"] }, "admin@example.test", ); @@ -126,12 +93,7 @@ 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], - ask: [], - allow: ["true"], - }); + await store.set({ mode: "enforce", deny: [rule], allow: ["true"] }); expect(store.get().deny).toEqual([rule]); await store.reset(); expect(store.get()).toEqual(configured);