diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-02 16:41:15 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-02 16:41:15 +0530 |
| commit | 0f717bc7f76ee82885cef4af534d1363d2550ec3 (patch) | |
| tree | cc4c37c7164dbde2923d5bf6c2f52c4235348cc9 /src | |
| parent | b7fd72a6aa065df6335d1b4710c07bb4fd67615e (diff) | |
feat(domains): migrate execute_js and run_script with path allowlist + timeout + return validation
Diffstat (limited to 'src')
| -rw-r--r-- | src/domains/js.ts | 197 | ||||
| -rw-r--r-- | src/registry.ts | 3 | ||||
| -rw-r--r-- | src/tools.ts | 200 |
3 files changed, 203 insertions, 197 deletions
diff --git a/src/domains/js.ts b/src/domains/js.ts new file mode 100644 index 0000000..bd4c3e1 --- /dev/null +++ b/src/domains/js.ts @@ -0,0 +1,197 @@ +import { readFile } from "node:fs/promises"; +import { resolve, isAbsolute } from "node:path"; +import { tmpdir } from "node:os"; +import { createRequire } from "node:module"; +import { Type } from "typebox"; +import { type Result, err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; +import { applyTruncation } from "../util/truncate"; + +// AsyncFunction constructor — the documented Node.js mechanism for compiling +// arbitrary user-supplied source. Equivalent to `new Function` but produces an +// async function we can `await`. The full RCE risk is contained at the source +// boundary: see the path allowlist + timeout below. A plain `unknown` cast +// would lose the constructor signature; we keep this single typed cast. +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as + new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>; + +// ESNext module — bare `require` is not in scope. createRequire provides the +// CJS resolver anchored to this file's URL so scripts can require() builtins +// and installed CJS packages. +const requireFromHere = createRequire(import.meta.url); + +const ExecuteJsArgs = Type.Object({ + expression: Type.String({ description: "JavaScript expression. `return X` is auto-wrapped in an IIFE." }), + targetId: Type.Optional(Type.String({ description: "Optional iframe targetId; default = current page." })), +}); + +export const executeJsTool = defineBrowserTool({ + name: "browser_execute_js", + label: "Browser Execute JS", + description: + "Run JavaScript in the page (or a specific iframe target). `return X` gets auto-wrapped in an IIFE for convenience. Always use safeJs / JSON.stringify when interpolating untrusted strings into source.", + promptSnippet: "Execute JS in the page (or iframe)", + promptGuidelines: [ + "`return foo` is auto-wrapped in an IIFE — both `foo` and `(() => foo)()` work.", + "For iframes, get a targetId via browser_execute_js with `Object.values(document.querySelectorAll('iframe'))` then call browser_execute_js with targetId.", + "Result must be JSON-serializable (Runtime.evaluate returnByValue=true).", + ], + parameters: ExecuteJsArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + const r = await client.evaluateJs(args.expression, args.targetId); + if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); + const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data); + const truncated = await applyTruncation(valueStr, "js"); + return ok({ + text: truncated.text, + details: truncated.fullOutputPath !== undefined + ? { valueLength: valueStr.length, fullOutputPath: truncated.fullOutputPath } + : { valueLength: valueStr.length }, + }); + }, +}); + +const RunScriptArgs = Type.Object({ + path: Type.String({ description: "Absolute path to the script file (.js or .mjs)" }), + params: Type.Optional( + Type.Object({}, { additionalProperties: true, description: "Args passed to the script as `params`" }), + ), + timeoutMs: Type.Optional( + Type.Integer({ + default: 60_000, + minimum: 100, + maximum: 600_000, + description: "Hard timeout in ms. Default 60s, max 600s.", + }), + ), +}); + +const allowedRoots = (): ReadonlyArray<string> => { + const env = process.env["BH_SCRIPT_DIR"]; + return [tmpdir(), process.cwd(), ...(env !== undefined ? [env] : [])].map((d) => resolve(d)); +}; + +const isPathAllowed = (p: string): boolean => { + const abs = resolve(p); + return allowedRoots().some((root) => abs === root || abs.startsWith(`${root}/`)); +}; + +const MAX_SOURCE_BYTES = 1_000_000; + +type ContentItem = { readonly type: "text"; readonly text: string }; + +const isContentItem = (v: unknown): v is ContentItem => + typeof v === "object" && v !== null + && (v as Readonly<Record<string, unknown>>)["type"] === "text" + && typeof (v as Readonly<Record<string, unknown>>)["text"] === "string"; + +export const runScriptTool = defineBrowserTool({ + name: "browser_run_script", + label: "Browser Run Script", + description: + "Execute a temporary JavaScript script with full Node.js + browser-daemon access. Path must be inside tmpdir, cwd, or BH_SCRIPT_DIR. Mandatory timeout. The script is full RCE on the harness's process — only invoke scripts you wrote and reviewed.", + promptSnippet: "Run a temporary script with daemon + Node access (security-bounded)", + promptGuidelines: [ + "Write the script with the write tool first; pass its absolute path.", + "Path must be inside tmpdir, cwd, or BH_SCRIPT_DIR — otherwise rejected.", + "Default timeout is 60s; pass timeoutMs to extend (max 600s).", + "Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout.", + "Script MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }. Throw on errors.", + ], + parameters: RunScriptArgs, + async handler(args, { client, signal, onUpdate, extensionCtx }): Promise<Result<ToolOk, ToolErr>> { + if (!isAbsolute(args.path)) { + return err({ kind: "invalid_state", message: "Script path must be absolute" }); + } + if (!isPathAllowed(args.path)) { + return err({ + kind: "invalid_state", + message: `Script path outside allowed directories (allowed: ${allowedRoots().join(", ")})`, + }); + } + let source: string; + try { + source = await readFile(args.path, "utf8"); + } catch (e) { + return err({ + kind: "io_error", + message: `Failed to read script: ${e instanceof Error ? e.message : String(e)}`, + }); + } + if (source.length === 0) return err({ kind: "invalid_state", message: "Script is empty" }); + if (source.length > MAX_SOURCE_BYTES) { + return err({ kind: "invalid_state", message: `Script exceeds ${MAX_SOURCE_BYTES}B size cap` }); + } + + let executeFn: (...a: ReadonlyArray<unknown>) => Promise<unknown>; + try { + executeFn = new AsyncFunction( + "params", "daemon", "require", "signal", "onUpdate", "ctx", + "console", "fetch", "JSON", "Buffer", "setTimeout", "clearTimeout", + `"use strict";\n${source}`, + ); + } catch (e) { + return err({ + kind: "invalid_state", + message: `Syntax error: ${e instanceof Error ? e.message : String(e)}`, + }); + } + + const timeoutMs = args.timeoutMs ?? 60_000; + const ac = new AbortController(); + const onAbort = (): void => ac.abort(); + if (signal !== undefined) signal.addEventListener("abort", onAbort, { once: true }); + const timeoutTimer = setTimeout(() => ac.abort(), timeoutMs); + + try { + const scriptPromise = executeFn( + args.params ?? {}, + client, // bound as `daemon` — name preserved for back-compat + requireFromHere, + ac.signal, + (u: unknown) => { + if (typeof u !== "object" || u === null) return; + const content = (u as Readonly<Record<string, unknown>>)["content"]; + if (!Array.isArray(content) || content.length === 0) return; + const first = content[0]; + if (typeof first !== "object" || first === null) return; + const txt = (first as Readonly<Record<string, unknown>>)["text"]; + if (typeof txt !== "string") return; + try { onUpdate({ text: txt }); } catch { /* swallow */ } + }, + extensionCtx ?? { cwd: process.cwd() }, + console, fetch, JSON, Buffer, setTimeout, clearTimeout, + ); + const abortPromise = new Promise<never>((_, reject) => { + ac.signal.addEventListener("abort", () => reject(new Error("script aborted (timeout or cancellation)")), { once: true }); + }); + const result = await Promise.race([scriptPromise, abortPromise]); + clearTimeout(timeoutTimer); + if (signal !== undefined) signal.removeEventListener("abort", onAbort); + + if (typeof result !== "object" || result === null) { + return err({ kind: "invalid_state", message: `Script must return an object; got ${JSON.stringify(result)}` }); + } + const content = (result as Readonly<Record<string, unknown>>)["content"]; + if (!Array.isArray(content)) { + return err({ kind: "invalid_state", message: "Script return value must have a content array" }); + } + if (!content.every(isContentItem)) { + return err({ kind: "invalid_state", message: "Script content array must contain { type: 'text', text: string } items" }); + } + const textOut = content.map((c) => c.text).join("\n"); + const details = (result as Readonly<Record<string, unknown>>)["details"]; + const isPlainObject = typeof details === "object" && details !== null && !Array.isArray(details); + return isPlainObject + ? ok({ text: textOut, details: details as Readonly<Record<string, unknown>> }) + : ok({ text: textOut }); + } catch (e) { + clearTimeout(timeoutTimer); + if (signal !== undefined) signal.removeEventListener("abort", onAbort); + return err({ + kind: "internal", + message: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`, + }); + } + }, +}); diff --git a/src/registry.ts b/src/registry.ts index 892d656..5f9d7da 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -14,6 +14,7 @@ import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files"; import { viewportResizeTool } from "./domains/viewport"; import { dragAndDropTool } from "./domains/drag"; import { httpGetTool, getNetworkLogTool } from "./domains/network"; +import { executeJsTool, runScriptTool } from "./domains/js"; const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ clickTool, @@ -42,6 +43,8 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ dragAndDropTool, httpGetTool, getNetworkLogTool, + executeJsTool, + runScriptTool, ]; export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => { diff --git a/src/tools.ts b/src/tools.ts index b1d27a9..0c8a05b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -95,201 +95,7 @@ async function applyTruncation( // ── Registration ───────────────────────────────────────────────────────────── -export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { -// ═══════════════════════════════════════════════════════════════════════════ - // browser_execute_js - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "browser_execute_js", - label: "Browser Execute JS", - description: - 'Execute JavaScript in the current page and return the result. Use for data extraction, DOM inspection, and page manipulation. Expressions with "return" are automatically wrapped in an IIFE.', - promptSnippet: "Execute JavaScript in the page and return the result", - promptGuidelines: [ - "Use browser_execute_js for extracting structured data from the page (text, attributes, JSON from embedded scripts).", - "Use browser_execute_js for DOM inspection when coordinates from screenshots aren't sufficient.", - "For bulk data extraction, prefer browser_http_get for API calls — it's 10-50x faster than browser DOM scraping.", - "To execute JS inside an iframe, first find the iframe target via browser_list_tabs (includeChrome: true to see iframe targets), then pass targetId to browser_execute_js.", - "Compositor clicks (browser_click) already pass through iframes — only use iframe JS when you need to read/change iframe DOM.", - ], - parameters: Type.Object({ - expression: Type.String({ description: "JavaScript expression to evaluate. 'return' statements are auto-wrapped in IIFE." }), - targetId: Type.Optional(Type.String({ description: "Target ID of an iframe to evaluate in (optional)" })), - }), - async execute(_id, params) { - try { - await daemon.ensureAlive(); - const value = await daemon.evaluateJS(params.expression, params.targetId); - const raw = value === undefined ? "undefined" : JSON.stringify(value, null, 2); - const { text, fullOutputPath } = await applyTruncation(raw, "js"); - return { - content: [{ type: "text" as const, text }], - details: { valueLength: raw.length, fullOutputPath }, - }; - } catch (err) { - return { - isError: true, - content: [{ type: "text" as const, text: `JS execution failed: ${err instanceof Error ? err.message : String(err)}` }], - details: undefined, - }; - } - }, - renderResult(result, { expanded }, theme) { - const details = result.details as { - valueLength?: number; - fullOutputPath?: string; - } | undefined; - - const len = details?.valueLength ?? 0; - - if (expanded) { - // Expanded (CTRL+O): show the full JS result - const content = result.content[0]; - const raw = content?.type === "text" ? content.text : ""; - return new Text(raw || "undefined", 0, 0); - } - - // Collapsed (default): size + first-line preview - const content = result.content[0]; - const raw = content?.type === "text" ? content.text : ""; - const firstLine = raw.split("\n")[0] || ""; - const preview = - firstLine.length > 120 ? firstLine.slice(0, 117) + "..." : firstLine; - - const summary = `📜 JS result (${formatSize(len)})`; - return new Text( - theme - ? theme.fg("dim", summary) + "\n" + theme.fg("muted", preview) - : summary + "\n" + preview, - 0, - 0, - ); - }, - }); - - // ═══════════════════════════════════════════════════════════════════════════ - // browser_run_script - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "browser_run_script", - label: "Browser Run Script", - description: - "Execute a temporary JavaScript script file with access to the browser daemon " + - "and Node.js APIs. Use this when the built-in browser_* tools are insufficient " + - "for a multi-step workflow — write a script to disk, then run it with this tool. " + - "The script receives these bindings in scope:\n" + - " params — the arguments passed to this tool\n" + - " daemon — the browser daemon (daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.)\n" + - " require — Node.js require() for builtins and installed packages\n" + - " signal — AbortSignal for cancellation\n" + - " onUpdate — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })\n" + - " ctx — ExtensionContext with cwd, sessionManager, ui, signal, etc.\n" + - " console, fetch, JSON, Buffer, setTimeout, clearTimeout\n\n" + - "The script MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }.\n" + - "For errors, throw: throw new Error('something went wrong').", - promptSnippet: "Run a temporary script with browser daemon access (write script to disk first)", - promptGuidelines: [ - "Use write to create a temporary script file, then browser_run_script to execute it — no dynamic tool registration needed.", - "Scripts are written to disk, making them auditable and re-runnable. The user can inspect them.", - "The script has access to the browser daemon via `daemon` — use daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.", - "For pure data processing or file operations, use require() to access Node.js modules like fs, path, crypto.", - "If the script only needs built-in browser_* tools, break those into separate tool calls before or after — do not try to invoke them from within the script.", - ], - parameters: Type.Object({ - path: Type.String({ description: "Path to the temporary script file (.js or .mjs) to execute" }), - params: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Optional parameters to pass to the script as `params`" })), - }), - async execute(_id, p, signal, onUpdate, ctx) { - const { path: scriptPath, params: scriptParams } = p as { - path: string; - params?: Record<string, unknown>; - }; - - // ── Read the script from disk ──────────────────────────────────── - let source: string; - try { - source = await readFile(scriptPath, "utf8"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - isError: true, - content: [{ - type: "text" as const, - text: `Failed to read script: ${msg}\n\nPath: ${scriptPath}\nCheck that the file exists and is readable.`, - }], - details: { error: "read_failed", message: msg }, - }; - } - - if (source.trim().length === 0) { - return { - isError: true, - content: [{ type: "text" as const, text: `Script is empty: ${scriptPath}` }], - details: { error: "empty_script" }, - }; - } - - // ── Compile ───────────────────────────────────────────────────── - let executeFn: (...args: unknown[]) => Promise<unknown>; - try { - const wrapped = `"use strict";\n${source}`; - executeFn = new AsyncFunction( - "params", "daemon", "require", "signal", "onUpdate", "ctx", - "console", "fetch", "JSON", "Buffer", "setTimeout", "clearTimeout", - wrapped, - ); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - isError: true, - content: [{ - type: "text" as const, - text: `Syntax error in script: ${msg}\n\nFile: ${scriptPath}\nFix the JavaScript syntax and try again.`, - }], - details: { error: "syntax_error", message: msg }, - }; - } - - // ── Execute ───────────────────────────────────────────────────── - try { - const result = await executeFn( - scriptParams ?? {}, - daemon, - require, - signal, - onUpdate ?? ((_update: unknown) => {}), - ctx ?? { cwd: process.cwd() }, - console, - fetch, - JSON, - Buffer, - setTimeout, - clearTimeout, - ); - - if (!result || typeof result !== "object" || !Array.isArray((result as any).content)) { - return { - isError: true, - content: [{ - type: "text" as const, - text: `Script must return { content: [{ type: 'text', text: '...' }], details?: {...} }.\nGot: ${JSON.stringify(result)}\n\nFile: ${scriptPath}`, - }], - details: { error: "invalid_return" }, - }; - } - - return result as { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> | undefined }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - isError: true, - content: [{ - type: "text" as const, - text: `Script execution failed: ${msg}\n\nFile: ${scriptPath}\nCheck for runtime errors, undefined variables, or broken require() calls.`, - }], - details: { error: "execution_failed", message: msg }, - }; - } - }, - }); +// All tools migrated to src/domains/* in v0.3 — see registerAllTools. +export function registerTools(_pi: ExtensionAPI, _daemon: BrowserDaemon): void { + // intentionally empty } |
