Skip to content

Commit f80dd01

Browse files
fix(agent): stop network tools being granted a standing approval
READ_ONLY_TOOLS gated the "always allow this session" button. It included capture_page_screenshot, which fetches a model-chosen URL and writes a PNG into the workspace — not read-only in either sense — along with web_search, fetch_url and the three github_* tools. Those five are read-only with respect to the workspace, but each sends a request to a destination the model chooses. Agent mode reads file contents and web pages, and instructions embedded in that content can steer later tool calls, so a standing grant on fetch_url becomes an outbound channel for whatever the model has already read — firing every turn with no prompt shown. The classification moves to lib/tool-approval.ts so it can be unit-tested (Chat.tsx has no component tests) and lists only what qualifies, keeping it default-deny for tools added later. Network tools still work exactly as before; they just keep their per-call prompt.
1 parent 07895f0 commit f80dd01

3 files changed

Lines changed: 80 additions & 26 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from "vitest";
2+
import { canAlwaysAllow } from "./tool-approval";
3+
4+
describe("canAlwaysAllow", () => {
5+
it("allows a standing grant for workspace-local reads", () => {
6+
expect(canAlwaysAllow("read_file")).toBe(true);
7+
expect(canAlwaysAllow("list_dir")).toBe(true);
8+
expect(canAlwaysAllow("git_diff")).toBe(true);
9+
expect(canAlwaysAllow("find_symbol_references")).toBe(true);
10+
});
11+
12+
// A standing grant on these would let content the model reads steer an
13+
// outbound request on every later turn with no prompt shown.
14+
it("never allows a standing grant for tools that reach the network", () => {
15+
expect(canAlwaysAllow("fetch_url")).toBe(false);
16+
expect(canAlwaysAllow("web_search")).toBe(false);
17+
expect(canAlwaysAllow("github_read_file")).toBe(false);
18+
expect(canAlwaysAllow("github_list_repositories")).toBe(false);
19+
expect(canAlwaysAllow("github_repository_tree")).toBe(false);
20+
});
21+
22+
// Not read-only in either sense: it fetches a model-chosen URL and writes
23+
// a PNG into the workspace.
24+
it("never allows a standing grant for capture_page_screenshot", () => {
25+
expect(canAlwaysAllow("capture_page_screenshot")).toBe(false);
26+
});
27+
28+
it("never allows a standing grant for tools with side effects", () => {
29+
for (const tool of ["write_file", "run_command", "run_code", "apply_patch", "delete_path", "git_commit", "http_request", "write_to_terminal"]) {
30+
expect(canAlwaysAllow(tool)).toBe(false);
31+
}
32+
});
33+
34+
it("defaults to denying an unrecognised tool", () => {
35+
expect(canAlwaysAllow("some_future_tool")).toBe(false);
36+
});
37+
});

frontend/src/lib/tool-approval.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Which agent tools may be granted "always allow for this session".
2+
//
3+
// The bar for that grant is not "doesn't write to the workspace" — it's "the
4+
// user has nothing to lose by never being asked again". Those differ: a tool
5+
// can leave the workspace untouched and still reach the network or the disk.
6+
//
7+
// This matters because the model's inputs are not trusted. Agent mode reads
8+
// file contents and web pages, and instructions embedded in that content can
9+
// steer subsequent tool calls. The per-call approval prompt is what stops that
10+
// from becoming unattended action, so anything with a side effect the user
11+
// would want to see keeps its prompt. Notably that excludes:
12+
//
13+
// - web_search, fetch_url and the github_* tools, which each send a request
14+
// to a destination the model chooses — a standing grant on those is an
15+
// unattended outbound channel for whatever the model has already read.
16+
// - capture_page_screenshot, which fetches a model-chosen URL *and* writes a
17+
// PNG into the workspace, so it is not read-only in either sense.
18+
//
19+
// Listing what qualifies, rather than what doesn't, keeps this default-deny: a
20+
// tool added to AGENT_TOOLS without being classified here needs per-call
21+
// approval until someone decides otherwise.
22+
export const AUTO_APPROVABLE_TOOLS = new Set([
23+
"read_file",
24+
"find_files",
25+
"file_info",
26+
"list_dir",
27+
"search_files",
28+
"git_status",
29+
"git_diff",
30+
"git_log",
31+
"read_notes",
32+
"get_background_output",
33+
"list_background_commands",
34+
"find_symbol_references",
35+
"read_terminal_output",
36+
]);
37+
38+
export function canAlwaysAllow(toolName: string): boolean {
39+
return AUTO_APPROVABLE_TOOLS.has(toolName);
40+
}

frontend/src/pages/Chat.tsx

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import { speakText, stopSpeaking } from "@/lib/tts";
7272
import { computeLineDiff } from "@/lib/diff";
7373
import { useToast } from "@/components/toast";
7474
import { isTransientError } from "@/lib/transient-errors";
75+
import { canAlwaysAllow } from "@/lib/tool-approval";
7576
import {
7677
COMPACTION_BUDGET_TOKENS,
7778
COMPACTION_KEEP_RECENT,
@@ -116,36 +117,12 @@ const RAG_THRESHOLD_CHARS = 20_000;
116117
// the most recent window renders by default, with older ones revealed a
117118
// window at a time on request rather than all at once.
118119
const RENDER_WINDOW_SIZE = 60;
119-
// Read-only tools are safe to let the model call repeatedly without a fresh
120-
// click each time — write_file and run_command always require explicit
121-
// per-call approval since they have real, potentially irreversible effects.
120+
122121
interface PlanStep {
123122
text: string;
124123
done: boolean;
125124
}
126125

127-
const READ_ONLY_TOOLS = new Set([
128-
"read_file",
129-
"find_files",
130-
"file_info",
131-
"list_dir",
132-
"search_files",
133-
"git_status",
134-
"git_diff",
135-
"git_log",
136-
"web_search",
137-
"fetch_url",
138-
"read_notes",
139-
"github_list_repositories",
140-
"github_repository_tree",
141-
"github_read_file",
142-
"get_background_output",
143-
"list_background_commands",
144-
"capture_page_screenshot",
145-
"find_symbol_references",
146-
"read_terminal_output",
147-
]);
148-
149126
// Vision models can already reason over any attached image — these just save
150127
// re-typing a good prompt for the common "I attached a diagram/wireframe"
151128
// case. Selecting one fills the composer; the user can still edit before sending.
@@ -2233,7 +2210,7 @@ export default function Chat() {
22332210
>
22342211
<X className="size-3.5" /> {t.deny}
22352212
</Button>
2236-
{READ_ONLY_TOOLS.has(call.name) && (
2213+
{canAlwaysAllow(call.name) && (
22372214
<Button
22382215
size="sm"
22392216
variant="ghost"

0 commit comments

Comments
 (0)