Skip to content
Open
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
63 changes: 62 additions & 1 deletion src/tools/browser/browser-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,12 +326,73 @@ describe("browser tools on a fake backend", () => {
});

it("tabs list rendering includes the active marker", async () => {
const tool = buildBrowserTabsTool(backend);
const tool = buildBrowserTabsTool(backend, autoApprove());
const result = await tool.run({ action: "list" }, CTX);
expect(result.status).toBe("ok");
expect(result.summary).toContain("*[0] Example");
});

it("tabs new with http(s) URL does not require approval", async () => {
const gate = new ApprovalGate({
emit: (req) => gate.reject(req.approvalId, "should not be called"),
});
const tool = buildBrowserTabsTool(backend, {
approvals: gate,
approvalRequired: true,
});
const result = await tool.run(
{ action: "new", url: "https://example.com/docs" },
CTX,
);
expect(result.status).toBe("ok");
expect(backend.lastTabs).toEqual({
action: "new",
url: "https://example.com/docs",
});
});

it("tabs new requires approval for non-http(s) schemes", async () => {
const gate = new ApprovalGate({
emit: (req) => gate.reject(req.approvalId, "denied file scheme"),
});
const tool = buildBrowserTabsTool(backend, {
approvals: gate,
approvalRequired: true,
});
await expect(
tool.run({ action: "new", url: "file:///etc/passwd" }, CTX),
).rejects.toMatchObject({ name: "ApprovalDeniedError" });
expect(backend.lastTabs).toBeNull();
expect(backend.readyCalls).toBe(0);
});

it("tabs new with javascript: URL is gated the same way", async () => {
const gate = new ApprovalGate({
emit: (req) => gate.reject(req.approvalId, "denied js scheme"),
});
const tool = buildBrowserTabsTool(backend, {
approvals: gate,
approvalRequired: true,
});
await expect(
tool.run({ action: "new", url: "javascript:alert(1)" }, CTX),
).rejects.toMatchObject({ name: "ApprovalDeniedError" });
expect(backend.lastTabs).toBeNull();
});

it("tabs list never hits the URL approval gate", async () => {
const gate = new ApprovalGate({
emit: (req) => gate.reject(req.approvalId, "list must not ask"),
});
const tool = buildBrowserTabsTool(backend, {
approvals: gate,
approvalRequired: true,
});
const result = await tool.run({ action: "list" }, CTX);
expect(result.status).toBe("ok");
expect(backend.lastTabs?.action).toBe("list");
});

it("buildBrowserTools registers all browser tools", () => {
const registry = new ToolRegistry();
for (const tool of buildBrowserTools(backend, autoApprove())) {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function buildBrowserTools(
buildBrowserTypeTool(backend),
buildBrowserReadAriaTool(backend),
buildBrowserSearchTool(backend),
buildBrowserTabsTool(backend),
buildBrowserTabsTool(backend, dangerous),
buildBrowserScrollTool(backend),
];
}
34 changes: 30 additions & 4 deletions src/tools/browser/tabs.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { compressToolResult } from "../../compressor/result-compressor.js";
import type { ToolDefinition } from "../tool-registry.js";
import type { BrowserBackend, TabsInput } from "./browser-backend.js";
import { isSafeUrl } from "./navigate.js";
import {
requireApproval,
type DangerousToolOptions,
} from "../../approval/dangerous-tool.js";

export function buildBrowserTabsTool(backend: BrowserBackend): ToolDefinition {
export function buildBrowserTabsTool(
backend: BrowserBackend,
options: DangerousToolOptions,
): ToolDefinition {
return {
name: "browser.tabs",
description:
"Manage browser tabs: list, switch, close, or open a new one.",
"Manage browser tabs: list, switch, close, or open a new one. Opening a new tab with a non-http(s) URL requires approval (same gate as browser.navigate).",
readonly: false,
async run(rawArgs) {
async run(rawArgs, ctx) {
const action = rawArgs.action;
if (
action !== "list" &&
Expand All @@ -24,9 +32,27 @@ export function buildBrowserTabsTool(backend: BrowserBackend): ToolDefinition {
if (typeof rawArgs.index === "number" && Number.isInteger(rawArgs.index)) {
input.index = rawArgs.index;
}
if (typeof rawArgs.url === "string") {
if (typeof rawArgs.url === "string" && rawArgs.url.length > 0) {
input.url = rawArgs.url;
}

// Mirror browser.navigate: tabs.new with a URL is the same danger surface
// (file://, javascript:, data:, chrome://). Without this gate the model
// can exfiltrate local files or execute script by opening a secondary tab.
if (action === "new" && input.url !== undefined && !isSafeUrl(input.url)) {
await requireApproval(
options,
{
sessionId: ctx.sessionId,
tool: "browser.tabs",
reason: "non-http(s) URL requested for new tab",
preview: input.url,
affectedResources: [input.url],
},
ctx.signal,
);
}

await backend.ensureReady();
const result = await backend.tabs(input);
const rendered = result.tabs
Expand Down