diff --git a/frontend/src/lib/tool-approval.test.ts b/frontend/src/lib/tool-approval.test.ts new file mode 100644 index 0000000..272eeeb --- /dev/null +++ b/frontend/src/lib/tool-approval.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { canAlwaysAllow } from "./tool-approval"; + +describe("canAlwaysAllow", () => { + it("allows a standing grant for workspace-local reads", () => { + expect(canAlwaysAllow("read_file")).toBe(true); + expect(canAlwaysAllow("list_dir")).toBe(true); + expect(canAlwaysAllow("git_diff")).toBe(true); + expect(canAlwaysAllow("find_symbol_references")).toBe(true); + }); + + // A standing grant on these would let content the model reads steer an + // outbound request on every later turn with no prompt shown. + it("never allows a standing grant for tools that reach the network", () => { + expect(canAlwaysAllow("fetch_url")).toBe(false); + expect(canAlwaysAllow("web_search")).toBe(false); + expect(canAlwaysAllow("github_read_file")).toBe(false); + expect(canAlwaysAllow("github_list_repositories")).toBe(false); + expect(canAlwaysAllow("github_repository_tree")).toBe(false); + }); + + // Not read-only in either sense: it fetches a model-chosen URL and writes + // a PNG into the workspace. + it("never allows a standing grant for capture_page_screenshot", () => { + expect(canAlwaysAllow("capture_page_screenshot")).toBe(false); + }); + + it("never allows a standing grant for tools with side effects", () => { + for (const tool of ["write_file", "run_command", "run_code", "apply_patch", "delete_path", "git_commit", "http_request", "write_to_terminal"]) { + expect(canAlwaysAllow(tool)).toBe(false); + } + }); + + it("defaults to denying an unrecognised tool", () => { + expect(canAlwaysAllow("some_future_tool")).toBe(false); + }); +}); diff --git a/frontend/src/lib/tool-approval.ts b/frontend/src/lib/tool-approval.ts new file mode 100644 index 0000000..85bef6d --- /dev/null +++ b/frontend/src/lib/tool-approval.ts @@ -0,0 +1,40 @@ +// Which agent tools may be granted "always allow for this session". +// +// The bar for that grant is not "doesn't write to the workspace" — it's "the +// user has nothing to lose by never being asked again". Those differ: a tool +// can leave the workspace untouched and still reach the network or the disk. +// +// This matters because the model's inputs are not trusted. Agent mode reads +// file contents and web pages, and instructions embedded in that content can +// steer subsequent tool calls. The per-call approval prompt is what stops that +// from becoming unattended action, so anything with a side effect the user +// would want to see keeps its prompt. Notably that excludes: +// +// - web_search, fetch_url and the github_* tools, which each send a request +// to a destination the model chooses — a standing grant on those is an +// unattended outbound channel for whatever the model has already read. +// - capture_page_screenshot, which fetches a model-chosen URL *and* writes a +// PNG into the workspace, so it is not read-only in either sense. +// +// Listing what qualifies, rather than what doesn't, keeps this default-deny: a +// tool added to AGENT_TOOLS without being classified here needs per-call +// approval until someone decides otherwise. +export const AUTO_APPROVABLE_TOOLS = new Set([ + "read_file", + "find_files", + "file_info", + "list_dir", + "search_files", + "git_status", + "git_diff", + "git_log", + "read_notes", + "get_background_output", + "list_background_commands", + "find_symbol_references", + "read_terminal_output", +]); + +export function canAlwaysAllow(toolName: string): boolean { + return AUTO_APPROVABLE_TOOLS.has(toolName); +} diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 564353a..a2b83d3 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -72,6 +72,7 @@ import { speakText, stopSpeaking } from "@/lib/tts"; import { computeLineDiff } from "@/lib/diff"; import { useToast } from "@/components/toast"; import { isTransientError } from "@/lib/transient-errors"; +import { canAlwaysAllow } from "@/lib/tool-approval"; import { COMPACTION_BUDGET_TOKENS, COMPACTION_KEEP_RECENT, @@ -116,36 +117,12 @@ const RAG_THRESHOLD_CHARS = 20_000; // the most recent window renders by default, with older ones revealed a // window at a time on request rather than all at once. const RENDER_WINDOW_SIZE = 60; -// Read-only tools are safe to let the model call repeatedly without a fresh -// click each time — write_file and run_command always require explicit -// per-call approval since they have real, potentially irreversible effects. + interface PlanStep { text: string; done: boolean; } -const READ_ONLY_TOOLS = new Set([ - "read_file", - "find_files", - "file_info", - "list_dir", - "search_files", - "git_status", - "git_diff", - "git_log", - "web_search", - "fetch_url", - "read_notes", - "github_list_repositories", - "github_repository_tree", - "github_read_file", - "get_background_output", - "list_background_commands", - "capture_page_screenshot", - "find_symbol_references", - "read_terminal_output", -]); - // Vision models can already reason over any attached image — these just save // re-typing a good prompt for the common "I attached a diagram/wireframe" // case. Selecting one fills the composer; the user can still edit before sending. @@ -2233,7 +2210,7 @@ export default function Chat() { > {t.deny} - {READ_ONLY_TOOLS.has(call.name) && ( + {canAlwaysAllow(call.name) && (