From 62dc8eb3b5f842c149a8de9db16ed3dd8dd4a225 Mon Sep 17 00:00:00 2001 From: David McKay Date: Wed, 19 Aug 2026 19:17:39 -0700 Subject: [PATCH] Take back the two features that only worked on one machine The approval registry from #15 kept its pending questions in a Map in the process, and the repetition detector from #17 kept its counts the same way. Both are correct on a laptop and both stop being correct the moment a deployment runs a second server process, which is the deployment every part of this is aimed at: several processes behind a load balancer, serving a company. The failure mode is the reason to take them back rather than leave them and fix them later. Neither one breaks loudly. A question raised on one process and answered on another is reported as no longer open, which reads exactly like an expiry. Counts split across processes mean a rule written as `repeat.count >= 10` never fires, which reads exactly like a Bot behaving itself. A boundary that silently stops enforcing is worse than one that was never advertised, because the deployment is relying on it. The ask lists, the approval surface, the repetition context and the two audit event kinds go with them. What stays is everything that was already right: the stall watchdog, which tracks streams in the process that holds them and belongs there, and the client-side work from #16 and #19. The rule is now on the pull request template, stated before the work rather than at review. Our own gateway snapshot cache has the same problem and is next. --- .env.example | 47 +- .github/pull_request_template.md | 38 + .../components/channels/approval-request.tsx | 99 - app/src/lib/approvals.ts | 163 -- app/src/lib/copilot/computer-tools.tsx | 276 +- app/src/lib/copilot/plugin-tools.tsx | 36 +- app/src/lib/plugins/queries.ts | 98 +- app/src/routes/_authed/admin/audit.tsx | 57 +- app/src/routes/_authed/admin/boundaries.tsx | 138 +- docs/architecture.md | 43 +- docs/configuration.md | 2 +- server/drizzle/0001_gigantic_sumo.sql | 1 - server/drizzle/meta/0001_snapshot.json | 2510 ----------------- server/drizzle/meta/_journal.json | 7 - server/src/app.ts | 19 - server/src/audit.ts | 39 - server/src/computer/approval-routes.ts | 127 - server/src/computer/approvals.ts | 332 --- server/src/computer/gateway.ts | 368 +-- server/src/computer/policy-store.ts | 21 +- server/src/computer/policy.ts | 162 +- server/src/computer/repeat.ts | 336 --- server/src/computer/routes.ts | 114 +- server/src/config.ts | 34 - server/src/db/schema/computer.ts | 9 - server/src/index.ts | 25 - server/src/plugins/routes.ts | 29 - server/src/plugins/store.ts | 180 +- server/tests/approval-routes.test.ts | 284 -- server/tests/computer-approvals.test.ts | 279 -- server/tests/computer-gateway.test.ts | 497 +--- server/tests/computer-policy.test.ts | 308 +- server/tests/computer-repeat.test.ts | 393 --- server/tests/config.test.ts | 33 - server/tests/plugin-store.integration.test.ts | 136 +- .../policy-durability.integration.test.ts | 50 +- 36 files changed, 195 insertions(+), 7095 deletions(-) create mode 100644 .github/pull_request_template.md delete mode 100644 app/src/components/channels/approval-request.tsx delete mode 100644 app/src/lib/approvals.ts delete mode 100644 server/drizzle/0001_gigantic_sumo.sql delete mode 100644 server/drizzle/meta/0001_snapshot.json delete mode 100644 server/src/computer/approval-routes.ts delete mode 100644 server/src/computer/approvals.ts delete mode 100644 server/src/computer/repeat.ts delete mode 100644 server/tests/approval-routes.test.ts delete mode 100644 server/tests/computer-approvals.test.ts delete mode 100644 server/tests/computer-repeat.test.ts 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);