From f564822393a345c76ad37dfbc4579127c7722451 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 13:38:35 -0700 Subject: [PATCH 1/2] Let the boundary ask a person, instead of only saying yes or no The action policy had two answers, so every action a deployment was unsure about had to be permanently forbidden or permanently permitted. The cases people actually have are in between: it may do this, but I want to see the first one; it may spend money, but ask me over fifty pounds. The only approximation was to deny the action and have somebody take the wheel, which throws away the Bot's turn and everything it had worked out to reach it. Adds a third list. `ask` is evaluated after `deny` and before `allow`, and both halves of that position are load-bearing. It must not soften a deny, because a thing a deployment has forbidden is not up for renegotiation at a prompt. It must beat allow, because the shipped policy is `allow: ["true"]` and an ask checked afterwards would be unreachable, so the first rule anybody wrote would silently do nothing. A broken ask expression asks, the same way a broken deny denies. An answer is bound to a fingerprint of the exact action it was given for: the Bot, the tool, the ref, the key, the file path and the page. That binding is what makes this more than a dialog box, because without it an id granted for "click Place order" is spendable on "click Delete account". Approvals are single use and expire after ten minutes, and they live in memory: a pending question is about a live browser session and a live turn, and an approval that outlived the process would be a grant nobody remembers giving. Answering is audited as its own act by its own actor. The person who approves is usually not the one whose turn raised the question, the two happen minutes apart, and an approval that is given and never spent leaves no action row at all, so the request, the answer and the action are three rows joined by an approval id rather than one row with a flag on it. The surface holds the tool call open, polls for the answer, and re-issues the identical request with the approval attached, so an approved action costs the Bot some seconds rather than its turn. A declined one comes back as a refusal and Stop still works out of the wait. In dry-run an ask interrupts nobody and is only recorded. The whole promise of dry-run is that switching a policy on changes nothing, and a mode that started stopping people to ask questions would be a mode nobody dares switch on. --- .env.example | 15 +- .../components/channels/approval-request.tsx | 109 + app/src/components/computer/approvals.ts | 74 + app/src/lib/copilot/computer-tools.tsx | 151 +- app/src/routes/_authed/admin/audit.tsx | 30 +- app/src/routes/_authed/admin/boundaries.tsx | 127 +- docs/architecture.md | 20 +- 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/audit.ts | 17 + server/src/computer/approvals.ts | 232 ++ server/src/computer/gateway.ts | 303 +- server/src/computer/policy-store.ts | 21 +- server/src/computer/policy.ts | 88 +- server/src/computer/routes.ts | 163 +- server/src/db/schema/computer.ts | 9 + server/tests/computer-approvals.test.ts | 205 ++ server/tests/computer-gateway.test.ts | 230 +- server/tests/computer-policy.test.ts | 173 +- .../policy-durability.integration.test.ts | 50 +- 22 files changed, 4472 insertions(+), 65 deletions(-) create mode 100644 app/src/components/channels/approval-request.tsx create mode 100644 app/src/components/computer/approvals.ts create mode 100644 server/drizzle/0001_gigantic_sumo.sql create mode 100644 server/drizzle/meta/0001_snapshot.json create mode 100644 server/src/computer/approvals.ts create mode 100644 server/tests/computer-approvals.test.ts diff --git a/.env.example b/.env.example index 89d8a39..3ddc983 100644 --- a/.env.example +++ b/.env.example @@ -80,11 +80,22 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # What a Bot may do on its computer, as one JSON object. Absent uses the built-in default, which # permits the acting tools and forbids nothing, and records every action either way. # -# `deny` is evaluated first and beats `allow`. An empty `allow` permits nothing, a missing policy +# `deny` is evaluated first and beats everything. An empty `allow` permits nothing, a missing policy # permits nothing, and a rule that fails to parse denies rather than letting the action through. The # server refuses to start if this is set and malformed, so an invalid restriction never falls back to # permissive behavior. # +# `ask` is the third list, checked after `deny` and before `allow`. A match stops the Bot, puts the +# action in front of a person in the conversation, and carries on with the same action if they allow +# it, so the turn is not thrown away. Nothing an `ask` rule matches can be reached by a `deny` rule: +# forbidden stays forbidden and is never offered as a question. It has to beat `allow`, because the +# default below permits everything, and an ask checked afterwards would never fire. +# +# An answer is bound to the exact action it was given for, so allowing one button is not permission +# to press a different one, and it can only be spent once. Nobody answering within ten minutes is the +# same as nobody being asked: the action does not happen. In `dry-run` an ask interrupts nobody and is +# only recorded, because dry-run promises to change nothing. +# # 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`. @@ -99,7 +110,7 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # `enforce` blocks; `dry-run` decides and records but lets everything through, so a new rule can be # tried against real traffic before it starts refusing anybody's work. # -# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"allow":["true"]} +# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"ask":["intent == \"write_file\" && !matches(file.path, \"^notes/\")"],"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/app/src/components/channels/approval-request.tsx b/app/src/components/channels/approval-request.tsx new file mode 100644 index 0000000..e40435b --- /dev/null +++ b/app/src/components/channels/approval-request.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from "react"; +import { + answerApproval, + type PendingApproval, + readApprovals, +} from "@/components/computer/approvals"; +import { Button } from "@/components/ui/button"; + +/** + * 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 polls rather than being handed its question by the tool call that raised it. The tool call is a + * promise waiting on a server, with no way to push anything into its own rendering while it waits, + * and the server already holds the list. Polling costs a request a second while a Bot is acting, and + * buys a card that is correct even when a person answers from another tab. + */ +export function ApprovalRequest({ + botId, + /** False once the tool call finishes, so a card cannot outlive the action it is about. */ + active, +}: { + botId: string; + active: boolean; +}) { + const [asking, setAsking] = useState(null); + const [answering, setAnswering] = useState(false); + const [problem, setProblem] = useState(null); + + useEffect(() => { + if (!active) { + setAsking(null); + return; + } + let live = true; + const look = async () => { + const approvals = await readApprovals(botId); + // A failed read is not an answer. Holding the last question on screen through a blip is better + // than clearing the card out from under somebody who was reading it. + if (!live || !approvals) return; + setAsking(approvals.find((one) => one.granted === undefined) ?? null); + }; + void look(); + const timer = setInterval(() => void look(), 1_000); + return () => { + live = false; + clearInterval(timer); + }; + }, [botId, active]); + + const answer = useCallback( + async (granted: boolean) => { + if (!asking) return; + setAnswering(true); + const result = await answerApproval(botId, asking.id, granted); + setAnswering(false); + if (!result.ok) { + setProblem(result.error ?? "That answer could not be recorded."); + return; + } + // Cleared here rather than waiting for the next poll, 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. + setAsking(null); + setProblem(null); + }, + [asking, botId], + ); + + if (!asking) return null; + + return ( +
+

{asking.question}

+

+ {asking.rule} +

+
+ + + + Asked because of this rule. Allowing covers this one action. + +
+ {problem ? ( +

+ {problem} +

+ ) : null} +
+ ); +} diff --git a/app/src/components/computer/approvals.ts b/app/src/components/computer/approvals.ts new file mode 100644 index 0000000..2e1453a --- /dev/null +++ b/app/src/components/computer/approvals.ts @@ -0,0 +1,74 @@ +/** + * Reading and answering the questions a boundary raised, from the browser. + * + * Beside the control helpers rather than inside either thing that uses them, because two surfaces + * ask the same server the same question for different reasons: the card in the transcript is looking + * for something to put in front of a person, and the tool call is looking for its own answer. One + * shape for both, so they cannot disagree about what an unanswered question looks like. + */ + +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; +}; + +/** + * 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/computers/${botId}/approvals`, { + 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/computers/${botId}/approvals/${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.", + }; + } +} diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index ce480b1..3797c9e 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -1,6 +1,8 @@ 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 { readApprovals } from "@/components/computer/approvals"; import { ComputerView } from "@/components/computer/computer-view"; import { type ControlState, @@ -42,11 +44,105 @@ async function waitForPerson( return "gave up"; } +/** + * Wait for somebody to answer, then send the same call again with their answer on it. + * + * The retry is the whole reason this exists. A boundary that only stopped the Bot would cost the + * person the turn and everything the model had worked out to reach it, so the call is held open and + * re-issued unchanged; from the model's side an approved action looks exactly like an ordinary one + * that took a while. + */ +async function waitForApproval( + botId: string, + approvalId: string, + signal: AbortSignal | undefined, +): Promise<"granted" | "declined" | "gave up" | "cancelled"> { + const deadline = Date.now() + WAIT_FOR_PERSON_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"; +} + +/** + * 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, signal?: AbortSignal, +): Promise { + const outcome = await sendToComputer(botId, path, init, signal); + if (outcome.awaitingApproval !== true) return outcome; + + const approvalId = String(outcome.approvalId ?? ""); + 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.", + }; +} + +async function sendToComputer( + botId: string, + path: string, + init?: RequestInit, + signal?: AbortSignal, ): Promise { // Announce before the call so the screen can open while the action is running. reportComputerActivity(botId); @@ -75,6 +171,19 @@ async function callComputer( > | null; if (!response.ok) { + // Read before anything else a 409 can mean. The other two, stale refs and a person holding the + // wheel, are conditions the model reacts to; this one it must not see at all, because the caller + // above is going to wait and then send the very same request again. + if (response.status === 409 && body?.awaitingApproval === true) { + return { + ok: false, + awaitingApproval: true, + approvalId: body.approvalId ?? "", + question: body.question ?? "", + rule: body.rule ?? null, + reason: (body.error as string) ?? "Somebody is being asked about that.", + }; + } return { ok: false, reason: (body?.error as string) ?? "That did not work.", @@ -140,14 +249,24 @@ 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({ + botId, label, detail, running, refused, failed, }: { + /** + * Whose computer this action is on. Only the acting tools pass it; a line for reading the page + * has nothing anybody could be asked about. + */ + botId?: string; label: string; detail?: string; running?: boolean; @@ -157,13 +276,18 @@ function ActionLine({ failed?: boolean; }) { return ( - + <> + {botId ? ( + + ) : null} + + ); } @@ -211,6 +335,12 @@ export function ComputerTools() { }, 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. + */} +
), @@ -292,6 +422,7 @@ export function ComputerTools() { ), render: ({ args, result, status }) => ( ( ( ) : null} + {approval && typeof payload.reason === "string" ? ( +
+ {payload.reason} +
+ ) : null} {/* Show concrete policy rules, but suppress the uninformative default `true` allow rule. */} {decision.rule && decision.rule !== "true" ? (
{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 @@ -268,6 +291,9 @@ const DECISIONS: Record = { "computer.secret_supplied": "A person supplied a secret", "computer.reset": "The computer was reset", "computer.stopped": "A person pressed stop", + "computer.approval_requested": "The boundary asked a person", + "computer.approval_granted": "A person allowed it", + "computer.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 dd920c2..e012321 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -14,13 +14,16 @@ type PolicyMode = "dry-run" | "enforce"; type ActionPolicy = { mode: PolicyMode; deny: string[]; + ask: string[]; allow: string[]; }; +type Preset = { label: string; rule: string; cost?: string }; + /** * Presets are concrete CEL rules, not a separate policy language. */ -const PRESETS: { label: string; rule: string; cost?: string }[] = [ +const PRESETS: Preset[] = [ { label: "Never submit a form", // `key` exists only on keypress actions; guard it by tool name to keep other actions evaluable. @@ -39,6 +42,26 @@ const PRESETS: { label: string; rule: string; cost?: string }[] = [ }, ]; +/** + * The same rules a deployment might otherwise have had to forbid outright. + * + * Both of these are things a Bot is genuinely useful for and that nobody wants it doing unwatched + * the first few times, which is the whole shape of this list: the boundary an operator actually + * wants is rarely "never", it is "not without me". + */ +const ASK_PRESETS: Preset[] = [ + { + label: "Ask before submitting a form", + rule: '(intent == "activate" && contains(element.name, "submit")) || (tool.name == "computer_key" && key == "Enter")', + 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, }); @@ -49,6 +72,7 @@ function BoundariesPage() { const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [draft, setDraft] = useState(""); + const [askDraft, setAskDraft] = useState(""); const load = useCallback(async () => { try { @@ -127,6 +151,20 @@ function BoundariesPage() { setDraft(""); }; + /** + * The same rule can sit in both lists, and the deny wins. + * + * Not prevented, because an operator moving a rule from one list to the other will pass through + * that state, and refusing to save it would look like a bug. What it means is stated under the + * list instead, since the gateway decides deny first and an ask alongside it never fires. + */ + const addAskRule = (rule: string) => { + const trimmed = rule.trim(); + if (!trimmed || policy.ask.includes(trimmed)) return; + void save({ ...policy, ask: [...policy.ask, trimmed] }); + setAskDraft(""); + }; + return ( + + {policy.ask.length === 0 ? ( +

+ No rules. Nothing stops to ask. +

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

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

+
+
    {policy.allow.map((rule) => ( diff --git a/docs/architecture.md b/docs/architecture.md index 90b52c1..f75213a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,12 +58,20 @@ Policy rules can inspect: - `mcp.server`, `mcp.tool`, `mcp.effect` Rules use CEL expressions plus case-insensitive `contains()` and `matches()`. -Deny rules are evaluated before allow rules. The policy engine fails closed: a -missing or empty policy permits nothing, a broken deny rule denies, and a broken -allow rule does not permit. OpenBot's shipped startup default is explicit: -`deny: []` and `allow: ["true"]`, unless `AGENT_COMPUTER_POLICY` or a saved -administrator policy replaces it. A malformed configured policy stops server -startup. +Rules are evaluated in three lists, in order: `deny`, then `ask`, then `allow`. +The policy engine fails closed: a missing or empty policy permits nothing, a +broken deny rule denies, a broken ask rule asks, and a broken allow rule does not +permit. OpenBot's shipped startup default is explicit: `deny: []`, `ask: []` and +`allow: ["true"]`, unless `AGENT_COMPUTER_POLICY` or a saved administrator policy +replaces it. A malformed configured policy stops server startup. + +An `ask` match stops the action and puts it in front of a person in the +conversation, then carries on with the same call if they allow it. The pending +question lives in the server process, is bound to a fingerprint of the exact +action it was raised for, and is single use, so an approval cannot be replayed +against a different one. Answering writes `computer.approval_granted` or +`computer.approval_denied` under the answering person's own actor, separately +from the action row. In `dry-run` an ask is recorded and interrupts nobody. ## Computers diff --git a/docs/configuration.md b/docs/configuration.md index b3b67fb..5b6b9a6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,7 +66,7 @@ Google OAuth client id and secret must be configured together. If Google OAuth i | `COMPUTER_SUPERVISOR_URL` | Supervisor URL for per-Bot computers. If absent, Bots share `AGENT_COMPUTER_URL`. | | `SUPERVISOR_TOKEN` | Bearer token required by the supervisor. | | `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Local-only private-host browsing when `true`. Cloud metadata addresses are still refused. | -| `AGENT_COMPUTER_POLICY` | JSON action policy: `{"mode":"enforce","deny":[...],"allow":[...]}`. | +| `AGENT_COMPUTER_POLICY` | JSON action policy: `{"mode":"enforce","deny":[...],"ask":[...],"allow":[...]}`. | | `COMPUTER_RUNTIME` | Set to `runsc` to run supervised computers under gVisor. | `agent-computer` also reads: diff --git a/server/drizzle/0001_gigantic_sumo.sql b/server/drizzle/0001_gigantic_sumo.sql new file mode 100644 index 0000000..22458dd --- /dev/null +++ b/server/drizzle/0001_gigantic_sumo.sql @@ -0,0 +1 @@ +ALTER TABLE "action_policy" ADD COLUMN "ask" text[] DEFAULT '{}' NOT NULL; \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..edd784a --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2510 @@ +{ + "id": "c2caefc9-77dd-42f2-9d57-0cb3e87225d0", + "prevId": "084d702b-09fd-44df-a60a-22477be02359", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chunks": { + "name": "chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chunks_document_position_idx": { + "name": "chunks_document_position_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chunks_document_idx": { + "name": "chunks_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chunks_document_id_documents_id_fk": { + "name": "chunks_document_id_documents_id_fk", + "tableFrom": "chunks", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_cursors": { + "name": "connector_cursors", + "schema": "", + "columns": { + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_cursors_connector_instance_id_connector_instances_id_fk": { + "name": "connector_cursors_connector_instance_id_connector_instances_id_fk", + "tableFrom": "connector_cursors", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_instances": { + "name": "connector_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "connector_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connector_instances_credential_id_credentials_id_fk": { + "name": "connector_instances_credential_id_credentials_id_fk", + "tableFrom": "connector_instances", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_acls": { + "name": "document_acls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "acl_effect", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_acls_document_principal_effect_idx": { + "name": "document_acls_document_principal_effect_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_acls_principal_idx": { + "name": "document_acls_principal_idx", + "columns": [ + { + "expression": "principal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_acls_document_id_documents_id_fk": { + "name": "document_acls_document_id_documents_id_fk", + "tableFrom": "document_acls", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_connector_source_idx": { + "name": "documents_connector_source_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_connector_deleted_idx": { + "name": "documents_connector_deleted_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_connector_instance_id_connector_instances_id_fk": { + "name": "documents_connector_instance_id_connector_instances_id_fk", + "tableFrom": "documents", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_runs": { + "name": "sync_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sync_runs_connector_started_at_idx": { + "name": "sync_runs_connector_started_at_idx", + "columns": [ + { + "expression": "connector_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_runs_connector_instance_id_connector_instances_id_fk": { + "name": "sync_runs_connector_instance_id_connector_instances_id_fk", + "tableFrom": "sync_runs", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_subscriptions": { + "name": "webhook_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "connector_instance_id": { + "name": "connector_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": { + "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk", + "tableFrom": "webhook_subscriptions", + "tableTo": "connector_instances", + "columnsFrom": ["connector_instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ask": { + "name": "ask", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acl_effect": { + "name": "acl_effect", + "schema": "public", + "values": ["allow", "deny"] + }, + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.connector_type": { + "name": "connector_type", + "schema": "public", + "values": ["google_drive", "onedrive"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": ["model", "connector", "agent", "mcp"] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": ["pending", "running", "succeeded", "failed"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 540bea2..076f20a 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1786891733850, "tag": "0000_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1787171220703, + "tag": "0001_gigantic_sumo", + "breakpoints": true } ] } diff --git a/server/src/audit.ts b/server/src/audit.ts index 0ff5794..022f9a5 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -60,6 +60,23 @@ 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. + */ + "computer.approval_requested", + "computer.approval_granted", + "computer.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/approvals.ts b/server/src/computer/approvals.ts new file mode 100644 index 0000000..585d820 --- /dev/null +++ b/server/src/computer/approvals.ts @@ -0,0 +1,232 @@ +/** + * 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. + */ +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 gateway 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; + filePath?: string | undefined; + pageUrl?: string | 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; + /** + * 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; +}; + +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"; + }; + +/** + * Thrown when somebody answers a question that is not open any more. + * + * Its own type so the routes can report it as a conflict rather than a fault: nothing is broken, the + * question expired or was already answered, most likely in another tab. + */ +export class ApprovalNotPendingError extends Error { + constructor() { + super( + "That request is no longer waiting for an answer. It may have expired, or somebody else answered it.", + ); + this.name = "ApprovalNotPendingError"; + } +} + +/** + * 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.filePath ?? "", + subject.pageUrl ?? "", + ].join("\u0000"), + ) + .digest("hex"); +} + +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; + }) => 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: (id: 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, + 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, 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. + if (!approval || 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 b2337be..8a8a246 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -18,6 +18,13 @@ * The refs are opaque to the caller precisely so that the server holds the mapping. */ import { type AuditStore, recordAuditEvent } from "../audit"; +import { + type ApprovalRegistry, + ApprovalNotPendingError, + createApprovalRegistry, + fingerprintOf, + type PendingApproval, +} from "./approvals"; import type { ComputerClient } from "./client"; import { type ActionPolicy, @@ -50,6 +57,32 @@ export class ActionRefusedError extends Error { } } +/** + * The boundary wants a person's answer before this happens. + * + * Emphatically not an {@link ActionRefusedError}. A refusal is final and the Bot should say so and + * move on; this one is a pause, and the same Bot presenting the same request again with an approval + * on it is the intended next step rather than an attempt to get around anything. Collapsing the two + * would teach a model to give up on exactly the actions a deployment was willing to permit, which is + * the failure that makes an ask list worse than useless. + */ +export class ActionNeedsApprovalError extends Error { + /** What the caller presents once somebody has answered. */ + readonly approvalId: string; + /** The question in the words a person is being shown, so the Bot can say what it is waiting for. */ + readonly question: string; + /** The rule that asked, so the surface can name the boundary the way a refusal does. */ + readonly rule: string; + + constructor(approval: PendingApproval) { + super(approval.question); + this.name = "ActionNeedsApprovalError"; + this.approvalId = approval.id; + this.question = approval.question; + this.rule = approval.rule; + } +} + /** Who is asking. The gateway records this; it does not decide it. */ export type ActionActor = { /** The signed-in person, or the local actor when authentication is not configured. */ @@ -76,6 +109,15 @@ export type ComputerGatewayOptions = { auditStore: AuditStore; /** Absent denies everything. See evaluateActionPolicy. */ policy: () => ActionPolicy | undefined; + /** + * Where questions raised by the `ask` list wait for an answer. + * + * Owned by the gateway by default rather than wired in from outside, because an approval is only + * ever meaningful against the decision that raised it: a deployment that could hand this a second + * registry would be a deployment where an approval could be granted somewhere the action is not + * decided. Injectable only so a test can control its clock. + */ + approvals?: ApprovalRegistry; }; /** @@ -95,6 +137,7 @@ type CachedSnapshot = { export function createComputerGateway(options: ComputerGatewayOptions) { const { client, auditStore, supervisor } = options; const snapshots = new Map(); + const approvals = options.approvals ?? createApprovalRegistry(); /** * The computer, addressed as the Bot that is asking. @@ -156,6 +199,15 @@ export function createComputerGateway(options: ComputerGatewayOptions) { key?: string; /** 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 { @@ -190,6 +242,72 @@ export function createComputerGateway(options: ComputerGatewayOptions) { }; const decision = evaluateActionPolicy(options.policy(), context); + + /** + * A decision that wants a person, resolved before anything is recorded as having happened. + * + * Two outcomes and no third: either an approval already exists for this exact action, in which + * case the row below says so and names who gave it, or the question is opened and the call stops + * here. Nothing is written as allowed or refused in the second case, because neither happened: + * `computer.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, + filePath, + pageUrl, + }); + const presented = subject.approvalId + ? approvals.consume(subject.approvalId, fingerprint) + : undefined; + + if (presented?.ok) { + approvedBy = presented.approval.answeredBy ?? presented.approval.actor; + } 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. + const pending = approvals.request({ + botId, + actor: actor.id, + rule: decision.matched ?? "", + question: decision.reason, + fingerprint, + }); + await writeApprovalEvent(auditStore, "computer.approval_requested", { + 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, @@ -200,11 +318,12 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ...(subject.key ? { key: subject.key } : {}), filePath, pageUrl, - decision, + decision: settled, + ...(approvedBy ? { approvedBy } : {}), }); - if (!decision.forward) { - throw new ActionRefusedError(decision.reason, decision.matched); + if (!settled.forward) { + throw new ActionRefusedError(settled.reason, settled.matched); } let result: T; @@ -230,7 +349,8 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ref, filePath, pageUrl, - decision, + decision: settled, + ...(approvedBy ? { approvedBy } : {}), failure: error instanceof Error ? error.message : "The action failed.", }); throw error; @@ -243,10 +363,70 @@ export function createComputerGateway(options: ComputerGatewayOptions) { : result; } + /** Shared by grant and refuse, so a Yes and a No cannot drift apart in what they record. */ + async function answerApproval( + computerId: string, + botId: string, + actor: ActionActor, + approvalId: string, + granted: boolean, + ): Promise { + const answered = approvals.answer(approvalId, actor.id, granted); + if (!answered.ok) throw new ApprovalNotPendingError(); + await writeApprovalEvent( + auditStore, + granted ? "computer.approval_granted" : "computer.approval_denied", + { + botId, + actor, + computerId, + approval: answered.approval, + }, + ); + return answered.approval; + } + return { snapshot, read, + /** + * 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. + */ + pendingApprovals(botId: string) { + return approvals.pending(botId); + }, + + /** + * A person answering one question, audited as its own act by its own actor. + * + * Deliberately not folded into the action row. The person who approves is frequently not the one + * whose turn raised the question, the two happen minutes apart, and an approval that was given + * and then never spent, because the run was stopped or the page moved on, leaves no action row at + * all. A trail that only recorded consent alongside the thing it consented to would lose every + * one of those, and "who approved what" is the question this feature exists to be able to answer. + */ + async grantApproval( + computerId: string, + botId: string, + actor: ActionActor, + approvalId: string, + ) { + return answerApproval(computerId, botId, actor, approvalId, true); + }, + + async refuseApproval( + computerId: string, + botId: string, + actor: ActionActor, + approvalId: string, + ) { + return answerApproval(computerId, botId, actor, approvalId, false); + }, + /** * Handovers, recorded but not policy-gated. * @@ -448,13 +628,21 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, url: string, + /** + * An answer a person gave to this exact call, if one has been given. + * + * Last and optional on every acting method, so a caller that knows nothing about approvals + * behaves exactly as it did and a route that forgets to pass it fails by asking again rather + * than by acting unasked. + */ + approvalId?: string, ) { return govern( computerId, "computer_navigate", botId, actor, - { targetUrl: url }, + { targetUrl: url, ...(approvalId ? { approvalId } : {}) }, () => as(botId).navigate(url), ); }, @@ -465,13 +653,18 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: ClickInput, signal?: AbortSignal, + approvalId?: string, ) { return govern( computerId, "computer_click", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + ...(signal ? { signal } : {}), + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).click(input, signal), ); }, @@ -482,13 +675,18 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: TypeInput, signal?: AbortSignal, + approvalId?: string, ) { return govern( computerId, "computer_type", botId, actor, - { ref: input.ref, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + ...(signal ? { signal } : {}), + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).type(input, signal), ); }, @@ -499,6 +697,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: KeyInput, signal?: AbortSignal, + approvalId?: string, ) { return govern( computerId, @@ -507,7 +706,12 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor, // The key is part of the subject, so a rule can tell Enter from a letter. Form submission can // happen through a keypress as well as a click, so the policy context carries the key. - { ref: input.ref, key: input.key, ...(signal ? { signal } : {}) }, + { + ref: input.ref, + key: input.key, + ...(signal ? { signal } : {}), + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).key(input, signal), ); }, @@ -517,9 +721,15 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ScrollInput, + approvalId?: string, ) { - return govern(computerId, "computer_scroll", botId, actor, {}, () => - as(botId).scroll(input), + return govern( + computerId, + "computer_scroll", + botId, + actor, + { ...(approvalId ? { approvalId } : {}) }, + () => as(botId).scroll(input), ); }, @@ -535,13 +745,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ReadFileInput, + approvalId?: string, ) { return govern( computerId, "computer_read_file", botId, actor, - { filePath: input.path }, + { filePath: input.path, ...(approvalId ? { approvalId } : {}) }, () => as(botId).readFile(input), ); }, @@ -556,13 +767,17 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: ListFilesInput, + approvalId?: string, ) { return govern( computerId, "computer_list_files", botId, actor, - { filePath: input.path ?? "." }, + { + filePath: input.path ?? ".", + ...(approvalId ? { approvalId } : {}), + }, () => as(botId).listFiles(input), ); }, @@ -572,13 +787,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, input: WriteFileInput, + approvalId?: string, ) { return govern( computerId, "computer_write_file", botId, actor, - { filePath: input.path }, + { filePath: input.path, ...(approvalId ? { approvalId } : {}) }, () => as(botId).writeFile(input), ); }, @@ -673,6 +889,14 @@ async function write( filePath: string | undefined; pageUrl: string; decision: PolicyDecision; + /** + * Who allowed this, when the boundary asked and somebody said yes. + * + * On the action row as well as on the approval row, because the two are found by different + * questions: a reader following one Bot's actions should not have to go and correlate ids to + * discover that a person stood behind this particular click. + */ + approvedBy?: string; /** Set only when a permitted action was attempted and did not succeed. */ failure?: string; }, @@ -725,6 +949,7 @@ async function write( mode: entry.decision.mode, source: entry.decision.source, rule: entry.decision.matched, + ...(entry.approvedBy ? { approvedBy: entry.approvedBy } : {}), /** Present so the trail explains a dry-run row that was recorded as refused but still ran. */ carriedOut: entry.decision.forward, }, @@ -779,6 +1004,58 @@ async function writeControlEvent( }); } +/** + * One row for a question, and one for its answer. + * + * 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 + * person answering about one specific action, so the row has to name the action or a reader cannot + * tell what was agreed to. + * + * All three rows 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, + eventType: + | "computer.approval_requested" + | "computer.approval_granted" + | "computer.approval_denied", + entry: { + botId: string; + actor: ActionActor; + computerId: string; + approval: PendingApproval; + toolName?: string; + pageUrl?: string; + filePath?: string | undefined; + }, +) { + await recordAuditEvent(auditStore, { + eventType, + targetType: "computer", + targetId: entry.computerId, + ...(entry.actor.userId ? { actorUserId: entry.actor.userId } : {}), + payload: { + bot: entry.botId, + actor: entry.actor.id, + approval: entry.approval.id, + rule: entry.approval.rule, + // The question as a person read it. Element labels are things a page displays rather than + // things anybody typed, which is why the reason text is safe to keep here; see the note on + // `write` above. + reason: entry.approval.question, + ...(entry.toolName ? { action: entry.toolName } : {}), + ...(entry.pageUrl ? { page: entry.pageUrl } : {}), + ...(entry.filePath ? { file: entry.filePath } : {}), + }, + }); +} + function hostOf(url: string): string { try { return new URL(url).host; diff --git a/server/src/computer/policy-store.ts b/server/src/computer/policy-store.ts index 053ddd1..216f411 100644 --- a/server/src/computer/policy-store.ts +++ b/server/src/computer/policy-store.ts @@ -36,6 +36,7 @@ const CURRENT = "current"; export const DEFAULT_ACTION_POLICY: ActionPolicy = { mode: "enforce", deny: [], + ask: [], allow: ["true"], }; @@ -73,6 +74,7 @@ export function createPolicyStore( id: CURRENT, mode: next.mode, deny: next.deny, + ask: next.ask, allow: next.allow, updatedBy: by ?? null, updatedAt: new Date(), @@ -82,6 +84,7 @@ export function createPolicyStore( set: { mode: next.mode, deny: next.deny, + ask: next.ask, allow: next.allow, updatedBy: by ?? null, updatedAt: new Date(), @@ -113,6 +116,7 @@ export function createPolicyStore( current = { mode: row.mode as ActionPolicy["mode"], deny: [...row.deny], + ask: [...row.ask], allow: [...row.allow], }; return "the database"; @@ -124,6 +128,7 @@ function clone(policy: ActionPolicy): ActionPolicy { return { mode: policy.mode, deny: [...policy.deny], + ask: [...policy.ask], allow: [...policy.allow], }; } @@ -155,8 +160,12 @@ export function parseActionPolicy( }; } - const lists: Record<"deny" | "allow", string[]> = { deny: [], allow: [] }; - for (const key of ["deny", "allow"] as const) { + const lists: Record<"deny" | "ask" | "allow", string[]> = { + deny: [], + ask: [], + allow: [], + }; + for (const key of ["deny", "ask", "allow"] as const) { const value = candidate[key] ?? []; if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) { return { ok: false, error: `${key} must be a list of expressions.` }; @@ -164,5 +173,11 @@ export function parseActionPolicy( lists[key] = value as string[]; } - return { ok: true, policy: { mode, deny: lists.deny, allow: lists.allow } }; + // `ask` defaults to empty like the others, so a policy written before this list existed still + // parses and still means what it meant. A deployment that has never asked anybody anything is a + // deployment with no ask rules, not an invalid one. + return { + ok: true, + policy: { mode, deny: lists.deny, ask: lists.ask, allow: lists.allow }, + }; } diff --git a/server/src/computer/policy.ts b/server/src/computer/policy.ts index 7b18cee..f84ba71 100644 --- a/server/src/computer/policy.ts +++ b/server/src/computer/policy.ts @@ -11,8 +11,15 @@ * thought of; an expression language can express the one they thought of. This is also the language * the enterprise gateway already speaks, so a rule written here means the same thing there. * - * Precedence: deny beats allow. A rule that removes permission must never be + * Precedence: deny, then ask, then allow. A rule that removes permission must never be * defeated by a broader rule that grants it, or a company cannot reason about what it has forbidden. + * + * `ask` sits between the two, and both halves of that position are deliberate. It must not soften a + * `deny`, because a thing a deployment has forbidden is not up for renegotiation at a prompt, and an + * approval box in front of a tired person at the end of a task is not the review anybody signed off + * when they wrote the deny rule. And it must beat `allow`, because the policy this product ships with + * is `allow: ["true"]`: an ask evaluated after the allow list would be unreachable in the default + * configuration, so the first rule anybody ever wrote here would silently do nothing. */ import { evaluate } from "cel-js"; @@ -27,8 +34,18 @@ export type ActionPolicy = { * governance feature. */ mode: PolicyMode; - /** Evaluated first. Any expression true means refused, whatever `allow` says. */ + /** Evaluated first. Any expression true means refused, whatever `ask` or `allow` says. */ deny: string[]; + /** + * Any expression true means a person is asked before the action runs. + * + * The middle answer a boundary with two lists cannot give. "It may spend money, but not more than + * fifty pounds without me" and "it may do this, but I want to see the first one" are the shapes + * every deployment reaches for once it trusts a Bot enough to let it act at all, and until this + * list existed the only way to express either was to forbid the action and have a person take the + * wheel, which throws away the Bot's turn and everything it had worked out to get there. + */ + ask: string[]; /** Any expression true means permitted. Empty means nothing is permitted. */ allow: string[]; }; @@ -138,8 +155,15 @@ export type PolicyDecision = { mode: PolicyMode; /** Which expression decided it, so the audit row can say why and an operator can find the rule. */ matched: string | null; - /** Which list that expression came from. `default` means nothing matched and the floor applied. */ - source: "deny" | "allow" | "default"; + /** + * Which list that expression came from. `default` means nothing matched and the floor applied. + * + * `ask` is not a verdict on its own: it says the boundary wants a person's answer, and the caller + * decides what to do about that. The gateway either finds an approval already granted for this + * exact action or stops and asks; both outcomes are recorded with this source, so the trail can + * tell an action a person consented to from one nothing ever questioned. + */ + source: "deny" | "ask" | "allow" | "default"; /** True when the action should actually be carried out. False for a refusal in `enforce`. */ forward: boolean; /** Why, in words that go in front of a person. */ @@ -219,6 +243,7 @@ export function evaluateActionPolicy( ): PolicyDecision { const mode: PolicyMode = policy?.mode ?? "enforce"; const deny = policy?.deny ?? []; + const ask = policy?.ask ?? []; const allow = policy?.allow ?? []; // Deny first, and a broken deny expression still denies. One typo in a rule therefore blocks the @@ -239,6 +264,27 @@ export function evaluateActionPolicy( } } + // Ask second, and a broken ask expression asks. The same reasoning as the deny loop, with a gentler + // cost: a typo here interrupts somebody who was not expecting to be interrupted, which is a + // nuisance, whereas the alternative is a rule that quietly permits exactly the thing it was written + // to hold back. A boundary whose failures land on the permissive side is not a boundary. + for (const expression of ask) { + if (matches(expression, context, true)) { + return { + allowed: false, + mode, + matched: expression, + source: "ask", + // Nothing happens until somebody says so, except in dry-run, where the whole promise is that + // switching the policy on changes nothing. A dry-run ask is a note in the trail saying "here + // is where you would have been interrupted", which is precisely what an operator trying a + // rule out against real traffic wants to find out before it starts stopping anybody. + forward: mode === "dry-run", + reason: describeAsk(context), + }; + } + } + for (const expression of allow) { if (matches(expression, context, false)) { return { @@ -264,6 +310,40 @@ 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 { + const what = context.file + ? context.file.path + : context.element?.name + ? `“${context.element.name}” on ${context.page.host}` + : context.page.host || "this page"; + return `The Bot wants to ${ASK_VERBS[context.intent ?? ""] ?? "act on"} ${what}.`; +} + /** A refusal a person can act on: what was refused, and on what. */ function describeRefusal(context: PolicyContext, expression: string): string { // A file refusal must not be phrased as happening "on ": the workspace has nothing to do with diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 3ce1c28..19004e7 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -2,6 +2,7 @@ import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; +import { ApprovalNotPendingError } from "./approvals"; import { type ComputerClient, ComputerUnavailableError, @@ -13,6 +14,7 @@ import { } from "./client"; import { type ActionActor, + ActionNeedsApprovalError, ActionRefusedError, type ComputerGateway, } from "./gateway"; @@ -62,6 +64,7 @@ export function createComputerRoutes( routes.post("/:botId/navigate", requireUser, async (context) => { const body = (await context.req.json().catch(() => null)) as { url?: unknown; + approvalId?: unknown; } | null; if (typeof body?.url !== "string" || !body.url.trim()) { return context.json({ error: "A web address is required." }, 400); @@ -79,9 +82,13 @@ export function createComputerRoutes( : { userId: context.var.actor.id }), }, body.url.trim(), + asApprovalId(body), ), ); } catch (error) { + if (error instanceof ActionNeedsApprovalError) { + return awaitingApproval(context, error); + } if (error instanceof ActionRefusedError) { return context.json({ error: error.message, rule: error.rule }, 403); } @@ -108,12 +115,23 @@ export function createComputerRoutes( * * Each one hands the gateway the computer id, the Bot, the actor and the input, and does no checking * of its own beyond the shape of the request. Where a decision gets made is a single place. + * + * Each also passes through whatever `approvalId` the body carried. The route does not look at it + * or judge it: an approval means something only against the action the gateway is about to take, + * and a route that decided anything about it would be a second place deciding. */ routes.post("/:botId/click", requireUser, (context) => act(context, (botId, actor, body, signal) => { const ref = asRef(body); if (!ref) return badRef; - return gateway.click(botId, botId, actor, ref, signal); + return gateway.click( + botId, + botId, + actor, + ref, + signal, + asApprovalId(body), + ); }), ); @@ -134,6 +152,7 @@ export function createComputerRoutes( submit: body?.submit === true, }, signal, + asApprovalId(body), ); }), ); @@ -153,18 +172,68 @@ export function createComputerRoutes( ...(ref ?? {}), }, signal, + asApprovalId(body), ); }), ); routes.post("/:botId/scroll", requireUser, (context) => act(context, (botId, actor, body) => - gateway.scroll(botId, botId, actor, { - ...(typeof body?.deltaY === "number" ? { deltaY: body.deltaY } : {}), - }), + gateway.scroll( + botId, + botId, + actor, + { + ...(typeof body?.deltaY === "number" ? { deltaY: body.deltaY } : {}), + }, + asApprovalId(body), + ), ), ); + /** + * The questions this Bot is waiting on, and a person's answer to one. + * + * Polled by the surface exactly the way `/control` above is, and for the same reason: the thing + * being waited on happens on a server this browser tab has no other channel to. A Bot's turn is + * held open while this list has an unanswered entry in it. + */ + routes.get("/:botId/approvals", requireUser, (context) => + context.json({ + // Projected rather than returned whole. The fingerprint is the binding between an approval and + // its action and there is nothing on this surface that could do anything with it, so it stays + // on the server where it is compared. + approvals: gateway + .pendingApprovals(context.req.param("botId")) + .map((approval) => ({ + 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 } : {}), + })), + }), + ); + + routes.post("/:botId/approvals/:approvalId", requireUser, (context) => + act(context, (botId, actor, body) => { + // 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 { error: "Say whether this is allowed or not." }; + } + const approvalId = context.req.param("approvalId") ?? ""; + return body.granted + ? gateway.grantApproval(botId, botId, actor, approvalId) + : gateway.refuseApproval(botId, botId, actor, approvalId); + }), + ); + /** * Who has the wheel. Polled by the surface next to the screen, so the person sees the Bot ask for * help without reloading anything. @@ -299,11 +368,17 @@ export function createComputerRoutes( /** The Bot's files. Through the gateway, like every other acting call. */ routes.post("/:botId/files/list", requireUser, (context) => act(context, (botId, actor, body) => - gateway.listFiles(botId, botId, actor, { - ...(typeof body?.path === "string" && body.path.trim() - ? { path: body.path.trim() } - : {}), - }), + gateway.listFiles( + botId, + botId, + actor, + { + ...(typeof body?.path === "string" && body.path.trim() + ? { path: body.path.trim() } + : {}), + }, + asApprovalId(body), + ), ), ); @@ -312,7 +387,13 @@ export function createComputerRoutes( if (typeof body?.path !== "string" || !body.path.trim()) { return { error: "A file path is required." }; } - return gateway.readFile(botId, botId, actor, { path: body.path.trim() }); + return gateway.readFile( + botId, + botId, + actor, + { path: body.path.trim() }, + asApprovalId(body), + ); }), ); @@ -324,11 +405,17 @@ export function createComputerRoutes( if (typeof body?.contents !== "string") { return { error: "The contents to write are required." }; } - return gateway.writeFile(botId, botId, actor, { - path: body.path.trim(), - contents: body.contents, - append: body.append === true, - }); + return gateway.writeFile( + botId, + botId, + actor, + { + path: body.path.trim(), + contents: body.contents, + append: body.append === true, + }, + asApprovalId(body), + ); }), ); @@ -440,6 +527,14 @@ async function act( } return context.json(result as Record); } catch (error) { + if (error instanceof ActionNeedsApprovalError) { + return awaitingApproval(context, error); + } + // Somebody answered a question that had already closed, most likely from a second tab or after + // it expired. A conflict rather than a fault: nothing is broken and there is nothing to fix. + if (error instanceof ApprovalNotPendingError) { + return context.json({ error: error.message }, 409); + } // A policy refusal is the product working. 403 with the rule that refused it, so the surface can // tell the person which boundary they met rather than reporting a malfunction. if (error instanceof ActionRefusedError) { @@ -477,6 +572,44 @@ function isBadRequest(value: unknown): value is BadRequest { ); } +/** + * A boundary that wants a person, reported as 409 rather than 403. + * + * 403 already means one thing to everything downstream of here: a boundary refused you and that is + * final. The surface renders it as Blocked and the model is told to stop and say so. This is the + * opposite condition, nothing has been refused and somebody is being asked, so reusing 403 would + * make every ask rule read to a Bot as a deny rule and produce exactly the outcome the ask list + * exists to avoid: a turn thrown away on an action the deployment was willing to permit. + * + * 409 because the existing 409s on these routes already mean "not now, and here is what to do about + * it", which a stale snapshot and a person holding the wheel both are. `awaitingApproval` is what + * separates this from those, and the surface checks for it before it reads a 409 as anything else. + */ +function awaitingApproval( + context: ComputerContext, + error: ActionNeedsApprovalError, +) { + return context.json( + { + error: error.message, + awaitingApproval: true, + approvalId: error.approvalId, + question: error.question, + rule: error.rule, + }, + 409, + ); +} + +/** An answer being presented, if the caller carried one. Its meaning is decided at the gateway. */ +function asApprovalId( + body: Record | null, +): string | undefined { + return typeof body?.approvalId === "string" && body.approvalId + ? body.approvalId + : undefined; +} + function asRef( body: Record | null, ): { ref: string; snapshotId: number } | undefined { diff --git a/server/src/db/schema/computer.ts b/server/src/db/schema/computer.ts index 517384e..231f9d1 100644 --- a/server/src/db/schema/computer.ts +++ b/server/src/db/schema/computer.ts @@ -26,6 +26,15 @@ export const actionPolicy = pgTable("action_policy", { /** `enforce` or `dry-run`. Not an enum: the policy module owns that vocabulary. */ mode: text("mode").notNull(), deny: text("deny").array().notNull(), + /** + * The rules that stop and ask a person, rather than deciding on their own. + * + * Defaulted to empty rather than left nullable, so a deployment whose row was written before this + * list existed comes back up meaning what it meant: no questions, same two answers. A nullable + * column would put the same reasoning in every reader instead, and one of them would eventually + * read null as something other than "asks nobody anything". + */ + ask: text("ask").array().notNull().default([]), allow: text("allow").array().notNull(), /** Who last changed it, for the Admin page and the trail. */ updatedBy: text("updated_by"), diff --git a/server/tests/computer-approvals.test.ts b/server/tests/computer-approvals.test.ts new file mode 100644 index 0000000..4c261a6 --- /dev/null +++ b/server/tests/computer-approvals.test.ts @@ -0,0 +1,205 @@ +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), + }); +} + +describe("an approval", () => { + test("is spendable on the action it was granted for", () => { + const approvals = registry(); + const pending = ask(approvals); + approvals.answer(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); + approvals.answer(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); + approvals.answer(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); + approvals.answer(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); + approvals.answer(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); + approvals.answer(pending.id, "manager@example.test", false); + + const second = approvals.answer(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(approvals.answer(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); + approvals.answer(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); + approvals.answer(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("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" }, + ]) { + expect(fingerprintOf(changed)).not.toBe(fingerprintOf(CLICK)); + } + }); + + 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 eff68c0..98c3736 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; import type { AuditEventInput, AuditStore } from "../src/audit"; +import { ApprovalNotPendingError } from "../src/computer/approvals"; import type { ComputerClient } from "../src/computer/client"; import { + ActionNeedsApprovalError, ActionRefusedError, createComputerGateway, } from "../src/computer/gateway"; @@ -101,7 +103,14 @@ function fakeAudit() { } const ACTOR = { id: "dev-local-user" }; -const PERMISSIVE: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +/** A second person, with a real users row, so the approval rows can be told apart by who wrote them. */ +const MANAGER = { id: "manager-user", userId: "manager-user" }; +const PERMISSIVE: ActionPolicy = { + mode: "enforce", + deny: [], + ask: [], + allow: ["true"], +}; async function gatewayWith(policy: ActionPolicy | undefined) { const { client, calls } = fakeClient(); @@ -223,6 +232,7 @@ describe("the computer gateway", () => { const { gateway, calls, rows } = await gatewayWith({ mode: "dry-run", deny: ['contains(element.name, "submit")'], + ask: [], allow: ["true"], }); @@ -397,3 +407,221 @@ 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("computer.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, calls, rows } = await gatewayWith(ASKING); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + await gateway.grantApproval("default", "bot-1", MANAGER, asked.approvalId); + await gateway.click( + "default", + "bot-1", + ACTOR, + { 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([ + "computer.approval_requested", + "computer.approval_granted", + "computer.action_allowed", + ]); + const decision = rows[2]?.payload.decision as { + source?: string; + approvedBy?: string; + }; + expect(decision.source).toBe("ask"); + expect(decision.approvedBy).toBe("manager-user"); + }); + + test("credits the answer to whoever answered, not to whoever ran the Bot", async () => { + // The row for the answer is written under the answering person's own actor. Folding consent into + // the action row would attribute it to whoever was driving, which is the one thing an approval + // trail must never do. + const { gateway, rows } = await gatewayWith(ASKING); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + await gateway.grantApproval("default", "bot-1", MANAGER, asked.approvalId); + expect(rows[0]?.payload.actor).toBe("dev-local-user"); + expect(rows[1]?.payload.actor).toBe("manager-user"); + expect(rows[1]?.actorUserId).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, 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; + await gateway.grantApproval("default", "bot-1", MANAGER, asked.approvalId); + + 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, calls, rows } = await gatewayWith(ASKING); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + await gateway.refuseApproval("default", "bot-1", MANAGER, asked.approvalId); + await expect( + gateway.click( + "default", + "bot-1", + ACTOR, + { ref: "e9", snapshotId: 7 }, + undefined, + asked.approvalId, + ), + ).rejects.toThrow(ActionNeedsApprovalError); + + expect(calls).toEqual([]); + expect(rows[1]?.eventType).toBe("computer.approval_denied"); + }); + + test("answering a question nobody is asking any more is a conflict, not a fault", async () => { + const { gateway } = await gatewayWith(ASKING); + await expect( + gateway.grantApproval("default", "bot-1", MANAGER, "not-a-real-id"), + ).rejects.toThrow(ApprovalNotPendingError); + }); + + test("the question is visible to the surface while it is open", async () => { + const { gateway } = await gatewayWith(ASKING); + const asked = (await gateway + .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .catch((caught: unknown) => caught)) as ActionNeedsApprovalError; + + const waiting = gateway.pendingApprovals("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(gateway.pendingApprovals("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"]); + }); +}); diff --git a/server/tests/computer-policy.test.ts b/server/tests/computer-policy.test.ts index 5191793..c18d201 100644 --- a/server/tests/computer-policy.test.ts +++ b/server/tests/computer-policy.test.ts @@ -26,7 +26,12 @@ function context(overrides: Partial = {}): PolicyContext { }; } -const permissive: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +const permissive: ActionPolicy = { + mode: "enforce", + deny: [], + ask: [], + allow: ["true"], +}; describe("evaluateActionPolicy", () => { test("an absent policy refuses, rather than permitting everything", () => { @@ -38,7 +43,7 @@ describe("evaluateActionPolicy", () => { test("an empty allow list refuses", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: [], allow: [] }, + { mode: "enforce", deny: [], ask: [], allow: [] }, context(), ); expect(decision.allowed).toBe(false); @@ -89,7 +94,7 @@ describe("evaluateActionPolicy", () => { test("a broken allow expression does not permit", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: [], allow: ["also not ( valid"] }, + { mode: "enforce", deny: [], ask: [], allow: ["also not ( valid"] }, context(), ); expect(decision.allowed).toBe(false); @@ -101,6 +106,7 @@ describe("evaluateActionPolicy", () => { { mode: "dry-run", deny: ['contains(element.name, "submit")'], + ask: [], allow: ["true"], }, context(), @@ -160,10 +166,43 @@ describe("parseActionPolicy", () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.policy.deny).toEqual([]); + expect(result.policy.ask).toEqual([]); expect(result.policy.allow).toEqual([]); } }); + test("a policy written before the ask list existed still parses", () => { + // Every deployment already running has a saved policy with two lists in it. Rejecting one, or + // reading its absence as anything other than "asks nobody anything", would change what an + // existing boundary means at the moment the server came back up. + const result = parseActionPolicy({ + mode: "enforce", + deny: ['contains(element.name, "pay")'], + allow: ["true"], + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.policy.ask).toEqual([]); + }); + + test("keeps the ask rules it was given", () => { + const result = parseActionPolicy({ + mode: "enforce", + deny: [], + ask: ['intent == "write_file"'], + allow: ["true"], + }); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.policy.ask).toEqual(['intent == "write_file"']); + }); + + test("rejects an ask list that is not a list of expressions", () => { + expect(parseActionPolicy({ mode: "enforce", ask: "everything" }).ok).toBe( + false, + ); + expect(parseActionPolicy({ mode: "enforce", ask: [7] }).ok).toBe(false); + }); + test.each([ ["not an object", "nonsense"], ["a missing mode", { deny: [], allow: [] }], @@ -184,6 +223,7 @@ describe("the second door", () => { const policy = { mode: "enforce" as const, deny: ['tool.name == "computer_key" && key == "Enter"'], + ask: [], allow: ["true"], }; const refused = evaluateActionPolicy(policy, { @@ -224,6 +264,7 @@ describe("a rule written about what an action does", () => { const policy = { mode: "enforce" as const, deny: ['intent == "activate" && contains(element.name, "submit")'], + ask: [], allow: ["true"], }; @@ -312,7 +353,7 @@ describe("a rule that names an identifier only some actions carry", () => { test("unguarded, it refuses a navigation that has no key at all", () => { const decision = evaluateActionPolicy( - { mode: "enforce", deny: ['key == "Enter"'], allow: ["true"] }, + { mode: "enforce", deny: ['key == "Enter"'], ask: [], allow: ["true"] }, navigating, ); // Failing closed on an unevaluable rule is the safe answer. The shipped preset carries the guard @@ -325,6 +366,7 @@ describe("a rule that names an identifier only some actions carry", () => { { mode: "enforce", deny: ['tool.name == "computer_key" && key == "Enter"'], + ask: [], allow: ["true"], }, navigating, @@ -337,6 +379,7 @@ describe("a rule that names an identifier only some actions carry", () => { { mode: "enforce", deny: ['tool.name == "computer_key" && key == "Enter"'], + ask: [], allow: ["true"], }, { @@ -350,3 +393,125 @@ 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("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); + }); +}); diff --git a/server/tests/policy-durability.integration.test.ts b/server/tests/policy-durability.integration.test.ts index d767297..e385963 100644 --- a/server/tests/policy-durability.integration.test.ts +++ b/server/tests/policy-durability.integration.test.ts @@ -38,7 +38,7 @@ describe("a boundary set while running", () => { const before = createPolicyStore(configured, database); await before.load(); await before.set( - { mode: "enforce", deny: [rule], allow: ["true"] }, + { mode: "enforce", deny: [rule], ask: [], allow: ["true"] }, "admin@example.test", ); @@ -47,6 +47,24 @@ describe("a boundary set while running", () => { expect(after.get().deny).toEqual([rule]); }); + test("the rules that ask a person survive a restart too", async () => { + // The list that stops and asks has to be as durable as the one that refuses. A boundary that + // silently stopped asking after a deployment came back up would be indistinguishable, from the + // trail, from one whose questions were all answered yes. + const asking = 'intent == "write_file" && !matches(file.path, "^notes/")'; + const before = createPolicyStore(configured, database); + await before.set({ + mode: "enforce", + deny: [], + ask: [asking], + allow: ["true"], + }); + + const after = createPolicyStore(configured, database); + expect(await after.load()).toBe("the database"); + expect(after.get().ask).toEqual([asking]); + }); + test("a deployment that never set one gets its configured default", async () => { const store = createPolicyStore(configured, database); expect(await store.load()).toBe("configuration"); @@ -55,7 +73,12 @@ describe("a boundary set while running", () => { test("resetting forgets it, so a restart returns to configuration", async () => { const store = createPolicyStore(configured, database); - await store.set({ mode: "enforce", deny: [rule], allow: ["true"] }); + await store.set({ + mode: "enforce", + deny: [rule], + ask: [], + allow: ["true"], + }); await store.reset(); // The saved row is removed rather than overwritten, so changing what configuration says then @@ -67,8 +90,18 @@ describe("a boundary set while running", () => { test("setting twice keeps one row and the latest rule", async () => { const store = createPolicyStore(configured, database); - await store.set({ mode: "enforce", deny: ["first"], allow: ["true"] }); - await store.set({ mode: "dry-run", deny: ["second"], allow: ["true"] }); + await store.set({ + mode: "enforce", + deny: ["first"], + ask: [], + allow: ["true"], + }); + await store.set({ + mode: "dry-run", + deny: ["second"], + ask: [], + allow: ["true"], + }); const rows = await database.select().from(actionPolicy); // One boundary per deployment, by construction. Two rows would mean something has to choose. @@ -80,7 +113,7 @@ describe("a boundary set while running", () => { test("records who changed it", async () => { const store = createPolicyStore(configured, database); await store.set( - { mode: "enforce", deny: [rule], allow: ["true"] }, + { mode: "enforce", deny: [rule], ask: [], allow: ["true"] }, "admin@example.test", ); @@ -93,7 +126,12 @@ describe("a boundary set while running", () => { // bigger problems than an unsaved rule. const store = createPolicyStore(configured); expect(await store.load()).toBe("configuration"); - await store.set({ mode: "enforce", deny: [rule], allow: ["true"] }); + await store.set({ + mode: "enforce", + deny: [rule], + ask: [], + allow: ["true"], + }); expect(store.get().deny).toEqual([rule]); await store.reset(); expect(store.get()).toEqual(configured); From d542cf53c9bb8b990e86690dd140289b334724f0 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 19 Aug 2026 14:40:48 -0700 Subject: [PATCH 2/2] Ask about tool calls too, and put each question on the line that raised it The ask list was only half wired. `evaluateActionPolicy` judges a Bot's calls to other people's MCP servers as well as what it does in a browser, and that call site only knew about yes and no: an ask verdict does not forward, so it was thrown as a permanent refusal, with a sentence that read "The Bot wants to call ." because the neutral blanks a tool call is judged against were mistaken for a file path. Nobody was asked, no id existed to grant, and the third list quietly became a second deny list for every tool a deployment has added. That is the outcome the list exists to prevent, and it lands on the first rule most people write, which is about somebody else's system rather than about a button. The MCP path now asks the same way the computer does: the question is opened against the same registry, the route reports it as 409 with the same body the acting routes use, and the surface holds the call open and re-issues it with the answer attached. Its binding covers the arguments as well as the tool, because a call to somebody else's server is identified by what it says rather than by what it lands on, and "post the release note in the team channel" is not permission to post something else somewhere else. Answering moved to `/api/approvals`, its own surface rather than a pair of handlers under the computer. A deployment can run plugins without a browser, and a question raised where nobody can answer it is worse than a rule that never fired: the Bot waits out the full ten minutes and then reports that nobody answered, having never asked anybody. The one projection lives beside the registry now, so the fingerprint cannot leave the process through one handler while its sibling four lines away is careful to keep it in, and an answer names the Bot the question was actually about rather than the address it arrived at: otherwise the trail holds a grant filed under one Bot and the action it paid for filed under another. The three rows are `approval.requested`, `approval.granted` and `approval.denied`, no longer named for the computer, each filed against the thing the question was about. The card in the transcript is handed its question by the tool call that raised it. It used to poll the Bot's list and show the oldest unanswered entry, and nothing withdraws a question when a wait ends, so pressing Stop or reloading a tab left one open for the rest of its ten minutes: the next turn's card offered a person a stale question, recorded their Allow against it, and left the action they were actually looking at waiting for an answer that never came. `submit` is a policy attribute now. The type tool takes a flag meaning "and press Enter", the computer presses it itself, and no keypress ever arrives as an action of its own, so a boundary written about clicking and about `key` watched the one call that submits a single-field form go straight past it. Both form presets say it, and it is in the binding, because "fill the postcode in" is not "fill it in and send the form". --- .env.example | 13 +- .../components/channels/approval-request.tsx | 78 +++-- app/src/components/computer/approvals.ts | 74 ----- app/src/lib/approvals.ts | 163 ++++++++++ app/src/lib/copilot/computer-tools.tsx | 259 ++++++++-------- app/src/lib/copilot/plugin-tools.tsx | 36 ++- app/src/lib/plugins/queries.ts | 98 +++++- app/src/routes/_authed/admin/audit.tsx | 21 +- app/src/routes/_authed/admin/boundaries.tsx | 7 +- docs/architecture.md | 20 +- server/src/app.ts | 19 ++ server/src/audit.ts | 14 +- server/src/computer/approval-routes.ts | 127 ++++++++ server/src/computer/approvals.ts | 138 +++++++-- server/src/computer/gateway.ts | 115 +++---- server/src/computer/policy.ts | 58 +++- server/src/computer/routes.ts | 49 --- server/src/index.ts | 15 + server/src/plugins/routes.ts | 29 ++ server/src/plugins/store.ts | 174 ++++++++++- server/tests/approval-routes.test.ts | 284 ++++++++++++++++++ server/tests/computer-approvals.test.ts | 94 +++++- server/tests/computer-gateway.test.ts | 119 +++++--- server/tests/computer-policy.test.ts | 86 ++++++ server/tests/plugin-store.integration.test.ts | 136 ++++++++- 25 files changed, 1735 insertions(+), 491 deletions(-) delete mode 100644 app/src/components/computer/approvals.ts create mode 100644 app/src/lib/approvals.ts create mode 100644 server/src/computer/approval-routes.ts create mode 100644 server/tests/approval-routes.test.ts diff --git a/.env.example b/.env.example index 3ddc983..78ce7b9 100644 --- a/.env.example +++ b/.env.example @@ -96,21 +96,26 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # same as nobody being asked: the action does not happen. In `dry-run` an ask interrupts nobody and is # only recorded, because dry-run promises to change nothing. # +# Every list judges a Bot's calls to MCP servers as well as what it does in a browser, `ask` included, +# so a rule like `intent == "write_tool" && mcp.server == "jira"` stops the call and asks rather than +# refusing it. The questions and the answers are the same three audit rows either way. +# # Workspace and browser profile per Bot. Each Bot's computer is its own container with its own # volumes, so one Bot cannot read another's files or use another's logins, and every action records # which Bot took it. A rule can still restrict a single Bot with `bot.id`. # # Attributes: tool.name, bot.id, actor.id, page.url, page.host, element.ref/role/name/type, -# key, file.path, file.name, file.extension. +# key, submit, file.path, file.name, file.extension, mcp.server/tool/effect. # # Name every route to the same effect. A form submits from a keypress in any of its fields, so a rule -# that only blocks a Submit button does not block Enter from another field. The example below refuses -# Enter outright for that reason. +# that only blocks a Submit button does not block Enter from another field, and a Bot can ask the type +# tool to press Enter for it, which arrives as `submit` rather than as a keypress. The example below +# names all three. # Functions: contains(haystack, needle) and matches(value, pattern), both case-insensitive. # `enforce` blocks; `dry-run` decides and records but lets everything through, so a new rule can be # tried against real traffic before it starts refusing anybody's work. # -# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"ask":["intent == \"write_file\" && !matches(file.path, \"^notes/\")"],"allow":["true"]} +# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\") || submit"],"ask":["intent == \"write_file\" && !matches(file.path, \"^notes/\")"],"allow":["true"]} # How long one action waits for its element, in ms. Read by agent-computer, not the server. # ACTION_TIMEOUT_MS=10000 diff --git a/app/src/components/channels/approval-request.tsx b/app/src/components/channels/approval-request.tsx index e40435b..abc1bdb 100644 --- a/app/src/components/channels/approval-request.tsx +++ b/app/src/components/channels/approval-request.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState, useSyncExternalStore } from "react"; +import { Button } from "@/components/ui/button"; import { answerApproval, - type PendingApproval, - readApprovals, -} from "@/components/computer/approvals"; -import { Button } from "@/components/ui/button"; + closeQuestion, + questionOn, + watchQuestions, +} from "@/lib/approvals"; /** * A transcript line that grew two buttons, for the one action a boundary wanted a person to see. @@ -15,60 +16,47 @@ import { Button } from "@/components/ui/button"; * 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 polls rather than being handed its question by the tool call that raised it. The tool call is a - * promise waiting on a server, with no way to push anything into its own rendering while it waits, - * and the server already holds the list. Polling costs a request a second while a Bot is acting, and - * buys a card that is correct even when a person answers from another tab. + * 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({ - botId, - /** False once the tool call finishes, so a card cannot outlive the action it is about. */ - active, + /** The tool call this line is reporting. Undefined before the SDK has named it. */ + toolCallId, }: { - botId: string; - active: boolean; + toolCallId: string | undefined; }) { - const [asking, setAsking] = useState(null); + const asking = useSyncExternalStore(watchQuestions, () => + questionOn(toolCallId ?? ""), + ); const [answering, setAnswering] = useState(false); const [problem, setProblem] = useState(null); - useEffect(() => { - if (!active) { - setAsking(null); - return; - } - let live = true; - const look = async () => { - const approvals = await readApprovals(botId); - // A failed read is not an answer. Holding the last question on screen through a blip is better - // than clearing the card out from under somebody who was reading it. - if (!live || !approvals) return; - setAsking(approvals.find((one) => one.granted === undefined) ?? null); - }; - void look(); - const timer = setInterval(() => void look(), 1_000); - return () => { - live = false; - clearInterval(timer); - }; - }, [botId, active]); - const answer = useCallback( async (granted: boolean) => { if (!asking) return; setAnswering(true); - const result = await answerApproval(botId, asking.id, granted); + const result = await answerApproval( + asking.botId, + asking.approvalId, + granted, + ); setAnswering(false); if (!result.ok) { setProblem(result.error ?? "That answer could not be recorded."); return; } - // Cleared here rather than waiting for the next poll, 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. - setAsking(null); + // 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, botId], + [asking, toolCallId], ); if (!asking) return null; @@ -76,9 +64,11 @@ export function ApprovalRequest({ return (

    {asking.question}

    -

    - {asking.rule} -

    + {asking.rule ? ( +

    + {asking.rule} +

    + ) : null}