From 813207c95c067ab5c5c28daa5d7ecba487f2e5bc Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 25 Oct 2026 15:14:50 -0700 Subject: [PATCH 1/5] Judge a Bot's typing by the field and page, not the words it types safetyRequirement read a computer_type call's typed text along with its target, so the payments rule matched words in the content: a CRM note about a customer's "purchase orders" or a source field reading "Business Wire" was handed back to the person as a money movement, in the middle of filling in a form. Typed text is content, as reading a checkout page is already not paying; the field's name, the page address and the button say what the action is, so those are what the rules read for a typed entry. A news wire (business, news or press wire) no longer counts as a wire of money anywhere else either. --- server/src/approvals/policy.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/server/src/approvals/policy.ts b/server/src/approvals/policy.ts index 56606eeda7..f3cdf0cb60 100644 --- a/server/src/approvals/policy.ts +++ b/server/src/approvals/policy.ts @@ -103,7 +103,9 @@ const SAFETY_REQUIREMENTS: readonly { id: "payments", reason: "Paying, purchasing or moving money is always done by the person.", pattern: - /payment|\bpay(ing)?\b|pay[-_ ]?now|checkout|purchase|place[-_ ]?order|buy[-_ ]?now|\bwire\b|bank[-_ ]?transfer|transfer[-_ ]?(money|funds)|send[-_ ]?money|credit[-_ ]?card|card[-_ ]?number|\bcvv\b|\biban\b|routing[-_ ]?number|billing/i, + // A wire of money, not a news wire: "Business Wire" is where press releases go out, so a Bot + // filling in where its research came from would otherwise hand that typing to the person. + /payment|\bpay(ing)?\b|pay[-_ ]?now|checkout|purchase|place[-_ ]?order|buy[-_ ]?now|(?), text: undefined } + : candidate.args; const words = [ candidate.toolRef, candidate.scope, - flatten(candidate.args), + flatten(args), flatten(candidate.target), ].join(" "); const hit = SAFETY_REQUIREMENTS.find((entry) => entry.pattern.test(words)); From 8d535ab35167ff595dc6a598f45e688087caebf6 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 25 Oct 2026 15:14:50 -0700 Subject: [PATCH 2/5] Test that typing is judged by its field, and a news wire is not a payment On the candidate shape the computer records (the typed text in args, the field and page in target): notes about purchase orders and payments, and a Business Wire source, are not handed off; a card number field, a checkout page and a new password field still are, whatever is typed. A link to a release on Business Wire is not a payment; "Wire the deposit", "Send a wire" and "Start a wire transfer" still are. The assertion-shape allow-list follows the existing entry the new tests moved down. --- server/tests/approvals-policy.test.ts | 64 +++++++++++++++++++++++++++ server/tests/assertion-shape.test.ts | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/server/tests/approvals-policy.test.ts b/server/tests/approvals-policy.test.ts index 9d2027cab1..e741829fb3 100644 --- a/server/tests/approvals-policy.test.ts +++ b/server/tests/approvals-policy.test.ts @@ -224,6 +224,70 @@ describe("built-in safety requirements", () => { ), ).toBeUndefined(); }); + + test("what a Bot types into a field is content: the field and the page say what the action is", () => { + // As the computer describes a typing action: the field it types into and the page it is on. + const typing = (text: string, field: string, pageUrl: string) => + safetyRequirement( + candidate({ + toolRef: "computer_type", + scope: new URL(pageUrl).host, + args: { ref: "f1e145", text, submit: false, snapshotId: 3 }, + target: { + element: { ref: "f1e145", name: field, role: "textbox" }, + pageUrl, + snapshotId: 3, + }, + }), + )?.id; + const crm = "http://host.docker.internal:4900/accounts/new"; + // A new CRM account, filled in from research: its notes and source name purchase orders, + // a payments company, a news wire. None of it is paying. + expect( + typing( + "Expanded its AI agents for purchase orders, detention and dock workflows.", + "Notes", + crm, + ), + ).toBeUndefined(); + expect(typing("Business Wire; LinkedIn", "Source", crm)).toBeUndefined(); + expect( + typing("Payments platform for freight billing", "Notes", crm), + ).toBeUndefined(); + // A payment field or a checkout page still goes to the person, whatever is typed. + expect( + typing( + "4111 1111 1111 1111", + "Card number", + "https://shop.example/account", + ), + ).toBe("payments"); + expect(typing("1", "Quantity", "https://shop.example/checkout")).toBe( + "payments", + ); + expect( + typing("hunter2", "New password", "https://mail.example/settings"), + ).toBe("credentials"); + }); + + test("a news wire is not a wire of money", () => { + const clicking = (name: string) => + safetyRequirement( + candidate({ + toolRef: "computer_click", + scope: "news.example", + args: { ref: "e7" }, + target: { element: { ref: "e7", name, role: "link" } }, + }), + )?.id; + // Press releases go out on Business Wire; following one is not paying. + expect(clicking("Read the release on Business Wire")).toBeUndefined(); + expect(clicking("news wire")).toBeUndefined(); + // Moving money by wire still goes to the person. + expect(clicking("Wire the deposit to the vendor")).toBe("payments"); + expect(clicking("Send a wire")).toBe("payments"); + expect(clicking("Start a wire transfer")).toBe("payments"); + }); }); describe("the reviewer's safety classification", () => { diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 8ab9198f2b..83f13618c2 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -639,7 +639,7 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/approvals-policy.test.ts", - line: 715, + line: 779, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(continued[0]?.error).toBeUndefined();", From e21d5b117c0a6d1eec36071fa15dcf13206fde1c Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 25 Oct 2026 15:14:50 -0700 Subject: [PATCH 3/5] Wait for a Bot's browser step saved whole before checking it Before the browser runs a computer action, the server checks the call against the conversation's saved copy, waiting up to five seconds for it to appear. Intelligence saves a call's arguments delta by delta, behind the stream, and the wait stopped as soon as the call appeared: a read that found half the arguments refused the action ("That unanswered tool call is not saved in this conversation"), or threw when the half did not parse, which the route answers as "Supply a valid request". The check, lifted into savedToolCall, now waits until the saved call parses and matches, within the same five seconds, and still refuses one that never does. --- server/src/approvals/wiring.ts | 84 ++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/server/src/approvals/wiring.ts b/server/src/approvals/wiring.ts index 14aa31d4c9..c50389a978 100644 --- a/server/src/approvals/wiring.ts +++ b/server/src/approvals/wiring.ts @@ -13,6 +13,52 @@ import { createApprovalQuestions, type PersonQuestion } from "./questions"; import { createApprovalService } from "./service"; import { ApprovalRefusedError, type ApprovalStore } from "./types"; +type ThreadHistory = Awaited< + ReturnType["getThreadMessages"]> +>; + +/** + * The saved copy of the call the browser is about to run, read back from the conversation. + * + * Intelligence saves a streaming run's messages a moment behind the stream, and the browser runs + * the tool as soon as the call arrives. It saves the call's arguments delta by delta too, so a read + * can find the call with half its arguments: that is not a mismatch yet, any more than a call not + * saved yet is. Either is waited for briefly; one that never appears whole does not match, and the + * caller refuses it. + */ +export async function savedToolCall( + read: () => Promise, + snapshot: { toolCallId: string; toolName: string; args: unknown }, + wait: { attempts?: number; sleep?: (ms: number) => Promise } = {}, +): Promise<{ matches: boolean; answered: boolean }> { + const attempts = wait.attempts ?? 10; + const sleep = wait.sleep ?? ((ms: number) => Bun.sleep(ms)); + let history = await read(); + const matches = () => { + const call = history.messages + .flatMap((message) => message.toolCalls ?? []) + .find((saved) => saved.id === snapshot.toolCallId); + if (!call || call.name !== snapshot.toolName) return false; + try { + return isDeepStrictEqual(JSON.parse(call.args), snapshot.args); + } catch { + // Arguments still being saved. + return false; + } + }; + for (let attempt = 0; !matches() && attempt < attempts; attempt++) { + await sleep(500); + history = await read(); + } + return { + matches: matches(), + answered: history.messages.some( + (message) => + message.role === "tool" && message.toolCallId === snapshot.toolCallId, + ), + }; +} + /** * The deployment's approval service: the stored approvals, told the person's channel when one * opens; the question store; and a computer tool resumed only when its call is really saved in the @@ -120,35 +166,15 @@ export function createServerApprovalService(deps: { throw new ApprovalRefusedError( "That conversation is no longer available to this Bot and person.", ); - // Intelligence saves a streaming run's messages a moment behind the stream, and the browser runs - // the tool as soon as the call arrives. A call that is not saved yet is waited for briefly; one - // that never appears is refused below. - let history = await routineIntelligence.getThreadMessages({ - threadId: snapshot.threadId, - userId: ownerUserId, - }); - const findCall = () => - history.messages - .flatMap((message) => message.toolCalls ?? []) - .find((call) => call.id === snapshot.toolCallId); - for (let attempt = 0; !findCall() && attempt < 10; attempt++) { - await Bun.sleep(500); - history = await routineIntelligence.getThreadMessages({ - threadId: snapshot.threadId, - userId: ownerUserId, - }); - } - const canonicalCall = findCall(); - if ( - !canonicalCall || - canonicalCall.name !== snapshot.toolName || - !isDeepStrictEqual(JSON.parse(canonicalCall.args), snapshot.args) || - history.messages.some( - (message) => - message.role === "tool" && - message.toolCallId === snapshot.toolCallId, - ) - ) + const saved = await savedToolCall( + () => + routineIntelligence.getThreadMessages({ + threadId: snapshot.threadId, + userId: ownerUserId, + }), + snapshot, + ); + if (!saved.matches || saved.answered) throw new ApprovalRefusedError( "That unanswered tool call is not saved in this conversation.", ); From 7047b0be1f063a9dbf0b51212bf19bd6f55deb07 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 25 Oct 2026 15:14:50 -0700 Subject: [PATCH 4/5] Test that a step read while still being saved is waited for The saved copy is read as Intelligence returned it part-way through saving a click: half the JSON, then all of it but the last key, then whole. The call is matched rather than refused. A call that never appears, never matches, or never finishes saving is still refused once the wait runs out, and one that already has a result is reported as answered. --- server/tests/approvals-saved-call.test.ts | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 server/tests/approvals-saved-call.test.ts diff --git a/server/tests/approvals-saved-call.test.ts b/server/tests/approvals-saved-call.test.ts new file mode 100644 index 0000000000..8edf0b87dc --- /dev/null +++ b/server/tests/approvals-saved-call.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { savedToolCall } from "../src/approvals/wiring"; + +// What the browser asks to run: the Bot's click, as the live stream delivered it whole. +const snapshot = { + toolCallId: "call_trzjXH0dFVyoivzeFqY2kn4D", + toolName: "computer_click", + args: { ref: "f2e94", snapshotId: 7 }, +}; + +/** The conversation as Intelligence returns it, holding the click with these saved arguments. */ +const conversation = (args: string | null, answered = false) => + ({ + messages: [ + ...(args === null + ? [] + : [ + { + id: "1941c748-b06c-4cf6-89f0-a8904e89b22c", + role: "assistant", + toolCalls: [ + { id: snapshot.toolCallId, name: "computer_click", args }, + ], + }, + ]), + ...(answered + ? [{ id: "result", role: "tool", toolCallId: snapshot.toolCallId }] + : []), + ], + }) as never; + +/** Each read returns the next saved state, then keeps returning the last. */ +const reads = (...states: never[]) => { + let index = 0; + return async () => states[Math.min(index++, states.length - 1)]!; +}; +const noWait = { sleep: async () => {} }; + +describe("the saved copy of a call the browser is about to run", () => { + test("a call read while its arguments are still being saved is waited for, not refused", async () => { + // Intelligence saves a call's arguments delta by delta, behind the stream: a read can land + // after the call starts and before its arguments end. The first reads here are what run 112 + // of the Kite demo held part-way through: half the JSON, then all of it but the last key. + const saved = await savedToolCall( + reads( + conversation('{"ref":"f2e'), + conversation('{"ref":"f2e94"}'), + conversation('{"ref":"f2e94","snapshotId":7}'), + ), + snapshot, + noWait, + ); + expect(saved).toEqual({ matches: true, answered: false }); + }); + + test("a call that never appears, or never matches, is still refused once the wait runs out", async () => { + expect( + await savedToolCall(reads(conversation(null)), snapshot, noWait), + ).toMatchObject({ matches: false }); + expect( + await savedToolCall( + reads(conversation('{"ref":"e12","snapshotId":7}')), + snapshot, + noWait, + ), + ).toMatchObject({ matches: false }); + expect( + await savedToolCall(reads(conversation('{"ref":"f2e')), snapshot, noWait), + ).toMatchObject({ matches: false }); + }); + + test("a call that already has a result is reported as answered", async () => { + expect( + await savedToolCall( + reads(conversation('{"ref":"f2e94","snapshotId":7}', true)), + snapshot, + noWait, + ), + ).toEqual({ matches: true, answered: true }); + }); +}); From e5600657208a5bc3e65b865a16230a60308ac620 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 25 Oct 2026 15:14:50 -0700 Subject: [PATCH 5/5] Note that filling in a form is not mistaken for paying, and a step is not refused while it saves --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9bc54a70..ea630edc5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,25 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's team account is kept, with who answered it and when. It starts empty and nothing is backfilled. +### Filling in a form is no longer mistaken for paying + +The built-in safety rules (paying or moving money, credentials, security settings) read the words a +Bot typed as well as where it typed them, so a Bot entering a CRM note about a customer's purchase +orders, or a press release's source such as Business Wire, was stopped with "Paying, purchasing or +moving money is always done by the person" and the step handed back to you. Typing is now judged by +the field, the page and the button: a card number field, a checkout page or a new password field +still goes to you, whatever is typed. A news wire (business, news or press wire) on a link or button +no longer counts as wiring money; wiring money still does. + +### A Bot's browser step is no longer refused while it is being saved + +Before your browser runs a click or a typed entry for a Bot, the server checks it against the copy +saved in the conversation. That copy is saved a moment behind, a piece at a time, and a check that +read it half saved refused the step with "That unanswered tool call is not saved in this +conversation" or "Supply a valid request", so a form the Bot was filling in could stop partway or ask +for your approval twice. The check now waits, as it already did for a step not saved at all, until +the step is saved whole. + ### A Bot can choose from a dropdown A Bot filling in a form in its browser could not set a dropdown (a native `