Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,33 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
# What a Bot may do on its computer, as one JSON object. Absent uses the built-in default, which
# permits the acting tools and forbids nothing, and records every action either way.
#
# `deny` is evaluated first and beats `allow`. An empty `allow` permits nothing, a missing policy
# `deny` is evaluated first and beats everything. An empty `allow` permits nothing, a missing policy
# permits nothing, and a rule that fails to parse denies rather than letting the action through. The
# server refuses to start if this is set and malformed, so an invalid restriction never falls back to
# permissive behavior.
#
# `ask` is the third list, checked after `deny` and before `allow`. A match stops the Bot, puts the
# action in front of a person in the conversation, and carries on with the same action if they allow
# it, so the turn is not thrown away. Nothing an `ask` rule matches can be reached by a `deny` rule:
# forbidden stays forbidden and is never offered as a question. It has to beat `allow`, because the
# default below permits everything, and an ask checked afterwards would never fire.
#
# An answer is bound to the exact action it was given for, so allowing one button is not permission
# to press a different one, and it can only be spent once. Nobody answering within ten minutes is the
# same as nobody being asked: the action does not happen. In `dry-run` an ask interrupts nobody and is
# only recorded, because dry-run promises to change nothing.
#
# Every list judges a Bot's calls to MCP servers as well as what it does in a browser, `ask` included,
# so a rule like `intent == "write_tool" && mcp.server == "jira"` stops the call and asks rather than
# refusing it. The questions and the answers are the same three audit rows either way.
#
# Workspace and browser profile per Bot. Each Bot's computer is its own container with its own
# volumes, so one Bot cannot read another's files or use another's logins, and every action records
# which Bot took it. A rule can still restrict a single Bot with `bot.id`.
#
# Attributes: tool.name, bot.id, actor.id, page.url, page.host, element.ref/role/name/type,
# key, file.path, file.name, file.extension, repeat.count.
# key, submit, file.path, file.name, file.extension, mcp.server/tool/effect,
# repeat.count.
#
# repeat.count is how many times this Bot has just made this exact call, counting the one being
# decided. A stuck model retries, and each retry is a real action on somebody's live website that is
Expand All @@ -146,13 +162,14 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
# another server's tools over MCP are not counted at all.
#
# Name every route to the same effect. A form submits from a keypress in any of its fields, so a rule
# that only blocks a Submit button does not block Enter from another field. The example below refuses
# Enter outright for that reason.
# that only blocks a Submit button does not block Enter from another field, and a Bot can ask the type
# tool to press Enter for it, which arrives as `submit` rather than as a keypress. The example below
# names all three.
# Functions: contains(haystack, needle) and matches(value, pattern), both case-insensitive.
# `enforce` blocks; `dry-run` decides and records but lets everything through, so a new rule can be
# tried against real traffic before it starts refusing anybody's work.
#
# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"allow":["true"]}
# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\") || submit"],"ask":["intent == \"write_file\" && !matches(file.path, \"^notes/\")"],"allow":["true"]}

# How long two identical calls count as the same repetition, in ms. Three minutes unset, which
# assumes a retry loop is a model round trip apart: call the tool, read the failure, try again.
Expand Down
99 changes: 99 additions & 0 deletions app/src/components/channels/approval-request.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useCallback, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import {
answerApproval,
closeQuestion,
questionOn,
watchQuestions,
} from "@/lib/approvals";

/**
* A transcript line that grew two buttons, for the one action a boundary wanted a person to see.
*
* Not a modal, and the restraint is the point. A question about one click belongs where the click is
* being reported, in sequence with everything else the Bot did, so a person can see what led up to it
* without losing the conversation behind a dialog. A boundary that interrupts the whole screen is one
* people learn to dismiss, and an ask rule that gets reflexively approved is worse than no rule at
* all: it produces a record of consent that nobody actually gave.
*
* It draws the question its own tool call is waiting on, and nothing else. The alternative, asking
* the server what this Bot is waiting on and showing the first unanswered thing, cannot tell one
* question from another: a run that was stopped or a tab that was reloaded leaves its question open
* in the registry for the rest of the ten minutes, so the card would offer somebody a stale question
* on the line of an action nobody is being asked about, and record their Allow against the wrong
* one. The tool call that raised the question is the only thing that knows which one is its own, so
* it is what says so.
*/
export function ApprovalRequest({
/** The tool call this line is reporting. Undefined before the SDK has named it. */
toolCallId,
}: {
toolCallId: string | undefined;
}) {
const asking = useSyncExternalStore(watchQuestions, () =>
questionOn(toolCallId ?? ""),
);
const [answering, setAnswering] = useState(false);
const [problem, setProblem] = useState<string | null>(null);

const answer = useCallback(
async (granted: boolean) => {
if (!asking) return;
setAnswering(true);
const result = await answerApproval(
asking.botId,
asking.approvalId,
granted,
);
setAnswering(false);
if (!result.ok) {
setProblem(result.error ?? "That answer could not be recorded.");
return;
}
// Taken down here rather than waiting for the call to notice, so the buttons stop being
// pressable the moment the answer lands. The Bot's turn is still on the server working out
// what to do with it.
closeQuestion(toolCallId ?? "");
setProblem(null);
},
[asking, toolCallId],
);

if (!asking) return null;

return (
<div className="my-1.5 rounded-md border border-border bg-card px-3 py-2">
<p className="text-sm">{asking.question}</p>
{asking.rule ? (
<p className="mt-1 break-all font-mono text-muted-foreground text-xs">
{asking.rule}
</p>
) : null}
<div className="mt-2 flex items-center gap-2">
<Button
disabled={answering}
onClick={() => void answer(true)}
size="sm"
>
Allow
</Button>
<Button
disabled={answering}
onClick={() => void answer(false)}
size="sm"
variant="outline"
>
Deny
</Button>
<span className="text-muted-foreground text-xs">
Asked because of this rule. Allowing covers this one action.
</span>
</div>
{problem ? (
<p className="mt-2 text-destructive text-xs" role="alert">
{problem}
</p>
) : null}
</div>
);
}
163 changes: 163 additions & 0 deletions app/src/lib/approvals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* The questions a boundary raised, from the browser's side: reading them, answering them, and
* knowing which tool call each one belongs to.
*
* One module for all of it because the two halves have to agree. A tool call that met an `ask` rule
* holds itself open waiting for an answer, and the card a person answers on is drawn on that same
* tool call's line in the transcript. Those are different components on different render passes, so
* the id travels through here.
*
* A question is held against the tool call that raised it rather than against the Bot. The Bot's
* list is the wrong key: nothing withdraws a question when the wait around it ends, so pressing
* Stop, reloading the tab or a turn that errors all leave an unanswered entry sitting in the
* server's registry until it expires. A card that showed "the oldest thing this Bot is waiting on"
* would then put a stale question in front of somebody on an unrelated line, record their Allow
* against an action nobody is waiting for, and leave the action they were actually looking at
* waiting out the full ten minutes.
*/

/**
* How long the surface holds a tool call open for an answer, and how often it looks.
*
* Ten minutes matches the server's own window, so the wait ends because the question expired rather
* than because the two sides disagreed about when it had.
*/
const WAIT_FOR_ANSWER_MS = 10 * 60_000;
const WAIT_POLL_MS = 1_000;

export type PendingApproval = {
id: string;
botId: string;
/** The expression that asked, shown as a rule so a person can see which boundary they are at. */
rule: string;
/** What is about to happen, in one sentence. */
question: string;
requestedAt: string;
expiresAt: string;
/** Absent while nobody has answered. False is an answer. */
granted?: boolean;
answeredBy?: string;
};

/** A question one tool call is waiting on, as its own line in the transcript needs to draw it. */
export type OpenQuestion = {
approvalId: string;
botId: string;
question: string;
rule: string | null;
};

const open = new Map<string, OpenQuestion>();
const watchers = new Set<() => void>();

/**
* Say that this tool call is waiting on an answer, so its line can draw the card.
*
* Handed over rather than fetched again: the server said all of it in the reply that paused the
* call, and a card that re-derived its question from a list would be back to guessing which entry
* in that list was its own.
*/
export function openQuestion(toolCallId: string, question: OpenQuestion): void {
if (!toolCallId) return;
open.set(toolCallId, question);
for (const watcher of watchers) watcher();
}

/** The wait is over, whichever way it went. Nothing should still be offering buttons for it. */
export function closeQuestion(toolCallId: string): void {
if (!open.delete(toolCallId)) return;
for (const watcher of watchers) watcher();
}

export function questionOn(toolCallId: string): OpenQuestion | undefined {
return open.get(toolCallId);
}

export function watchQuestions(listener: () => void): () => void {
watchers.add(listener);
return () => {
watchers.delete(listener);
};
}

/**
* The open questions for one Bot, or null if the server could not be asked.
*
* Null and an empty list are kept apart on purpose. A caller waiting for its own answer must not read
* a failed request as "the question is gone", which is what an empty list means here.
*/
export async function readApprovals(
botId: string,
): Promise<PendingApproval[] | null> {
try {
const response = await fetch(`/api/approvals/${botId}`, {
credentials: "include",
});
if (!response.ok) return null;
const body = (await response.json()) as { approvals?: PendingApproval[] };
return body.approvals ?? [];
} catch {
return null;
}
}

export async function answerApproval(
botId: string,
approvalId: string,
granted: boolean,
): Promise<{ ok: boolean; error?: string }> {
try {
const response = await fetch(`/api/approvals/${botId}/${approvalId}`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ granted }),
});
if (response.ok) return { ok: true };
const body = (await response.json().catch(() => null)) as {
error?: string;
} | null;
return {
ok: false,
error: body?.error ?? "That answer could not be recorded.",
};
} catch {
return {
ok: false,
error: "The assistant's computer could not be reached.",
};
}
}

/**
* Hold a tool call open until somebody answers its question.
*
* Polled rather than pushed. The answer arrives on a server this tab has no other channel to, and it
* may well be given in a different tab or by a different person, so the only honest way to learn it
* is to keep asking. A second between looks costs one request while a Bot is stopped and nothing at
* all the rest of the time.
*/
export async function waitForApproval(
botId: string,
approvalId: string,
signal: AbortSignal | undefined,
): Promise<"granted" | "declined" | "gave up" | "cancelled"> {
const deadline = Date.now() + WAIT_FOR_ANSWER_MS;
while (Date.now() < deadline) {
// Stop must work out of this wait as well, or pressing it leaves a Bot parked on a question
// nobody is going to answer.
if (signal?.aborted) return "cancelled";
const approvals = await readApprovals(botId);
if (approvals) {
const mine = approvals.find((one) => one.id === approvalId);
// Gone from a list we did read means it expired and was swept, which is the same outcome as
// running out of patience here. A list we could NOT read says nothing, so it is not read as an
// answer.
if (!mine) return "gave up";
if (mine.granted === true) return "granted";
if (mine.granted === false) return "declined";
}
await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
}
return "gave up";
}
Loading