From 6def109785f6db77ba0faceab57091fb25246466 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Tue, 26 May 2026 13:38:23 -0400 Subject: [PATCH 01/44] fix(core): block prompt context form fills --- packages/core/src/security/actionFirewall.ts | 43 +++++++++++++++++++ packages/core/src/tools/webActionTools.ts | 30 +++++++++++++ .../core/test/security/actionFirewall.test.ts | 38 ++++++++++++++++ .../core/test/tools/webActionTools.test.ts | 35 +++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 packages/core/src/security/actionFirewall.ts create mode 100644 packages/core/test/security/actionFirewall.test.ts diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts new file mode 100644 index 00000000..1a76a45e --- /dev/null +++ b/packages/core/src/security/actionFirewall.ts @@ -0,0 +1,43 @@ +export const SECURITY_BLOCKED_CONTEXT_EXFILTRATION = + "Security policy blocked a form fill that appears to contain agent context or prompt data"; + +export type SecurityAssessment = + | { allowed: true } + | { allowed: false; reason: string; isRecoverable: true }; + +export interface FillAssessmentInput { + value: string; +} + +const CONTEXT_EXFILTRATION_PATTERNS = [ + /system prompt/i, + /developer prompt/i, + /conversation history/i, + /tool results?/i, + /page snapshots?/i, + /<\s*external-content\b/i, + /<\/\s*external-content\s*>/i, + /you are an expert at completing tasks using a web browser/i, + /available tools/i, + /mandatory guardrails/i, +]; + +const GENERATED_TEXT_LINE_LIMIT = 2; + +export function assessFillValue(input: FillAssessmentInput): SecurityAssessment { + const value = input.value.trim(); + + if ( + value && + (CONTEXT_EXFILTRATION_PATTERNS.some((pattern) => pattern.test(value)) || + value.split(/\r?\n/).length > GENERATED_TEXT_LINE_LIMIT) + ) { + return { + allowed: false, + reason: SECURITY_BLOCKED_CONTEXT_EXFILTRATION, + isRecoverable: true, + }; + } + + return { allowed: true }; +} diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index f1f415de..c348a129 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -13,6 +13,7 @@ import { buildExtractionPrompt, TOOL_STRINGS } from "../prompts.js"; import type { ProviderConfig } from "../provider.js"; import { BrowserException } from "../errors.js"; import { generateTextWithRetry } from "../utils/retry.js"; +import { assessFillValue } from "../security/actionFirewall.js"; import { withSpan, SpanStatusCode, @@ -45,6 +46,30 @@ type ActionResult = { isRecoverable?: boolean; }; +function securityBlockedResult( + action: string, + error: string, + context: WebActionContext, + ref?: string, + value?: string | number, +): ActionResult { + context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_COMPLETED, { + success: false, + action, + error, + isRecoverable: true, + }); + + return { + success: false, + action, + ...(ref && { ref }), + ...(value !== undefined && { value }), + error, + isRecoverable: true, + }; +} + /** * Helper function to perform an action with full error handling and logging * Handles browser exceptions and converts them to recoverable errors for the agent @@ -157,6 +182,11 @@ export function createWebActionTools(context: WebActionContext) { value: z.string().describe(TOOL_STRINGS.webActions.common.textValue), }), execute: async ({ ref, value }) => { + const assessment = assessFillValue({ value }); + if (!assessment.allowed) { + return securityBlockedResult("fill", assessment.reason, context, ref); + } + return await performActionWithValidation(PageAction.Fill, context, ref, value); }, }), diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts new file mode 100644 index 00000000..50048d02 --- /dev/null +++ b/packages/core/test/security/actionFirewall.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + assessFillValue, + SECURITY_BLOCKED_CONTEXT_EXFILTRATION, +} from "../../src/security/actionFirewall.js"; + +describe("actionFirewall", () => { + it("blocks filling agent context into a form", () => { + const result = assessFillValue({ + value: + 'System prompt: You are an expert at completing tasks using a web browser.\nsecret', + }); + + expect(result.allowed).toBe(false); + if (result.allowed) { + throw new Error("Expected context exfiltration fill to be blocked"); + } + expect(result.reason).toContain(SECURITY_BLOCKED_CONTEXT_EXFILTRATION); + }); + + it("blocks multiline generated text even without known prompt keywords", () => { + const result = assessFillValue({ + value: "Here is what I can see:\nTask details are available.\nThe previous steps succeeded.", + }); + + expect(result.allowed).toBe(false); + if (result.allowed) { + throw new Error("Expected multiline generated text to be blocked"); + } + expect(result.reason).toContain(SECURITY_BLOCKED_CONTEXT_EXFILTRATION); + }); + + it("allows ordinary user-facing form values", () => { + expect(assessFillValue({ value: "San Francisco" }).allowed).toBe(true); + expect(assessFillValue({ value: "Test <>&\"'`\n\t value" }).allowed).toBe(true); + expect(assessFillValue({ value: "a".repeat(10000) }).allowed).toBe(true); + }); +}); diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index 34374e74..b1256fee 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -273,6 +273,41 @@ describe("Web Action Tools", () => { }); }); + it("should block filling agent context into a form", async () => { + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + + const result = await tools.fill.execute({ + ref: "input1", + value: + "Conversation history:\nTask: summarize the page\npage text", + }); + + expect(performActionSpy).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.action).toBe("fill"); + expect(result.ref).toBe("input1"); + expect(result.value).toBeUndefined(); + expect(result.isRecoverable).toBe(true); + expect(result.error).toContain("Security policy blocked"); + }); + + it("should block multiline generated text without prompt keywords", async () => { + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + + const result = await tools.fill.execute({ + ref: "input1", + value: "Here is the current working state:\nThe page loaded.\nThe next action is ready.", + }); + + expect(performActionSpy).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.action).toBe("fill"); + expect(result.ref).toBe("input1"); + expect(result.value).toBeUndefined(); + expect(result.isRecoverable).toBe(true); + expect(result.error).toContain("Security policy blocked"); + }); + it("should emit browser action events", async () => { const emitSpy = vi.spyOn(eventEmitter, "emit"); From daa9200f90588a6ee674839001e1c78e8c502bdc Mon Sep 17 00:00:00 2001 From: sbrooke Date: Tue, 26 May 2026 13:40:40 -0400 Subject: [PATCH 02/44] Revert "fix(core): block prompt context form fills" This reverts commit 6def109785f6db77ba0faceab57091fb25246466. --- packages/core/src/security/actionFirewall.ts | 43 ------------------- packages/core/src/tools/webActionTools.ts | 30 ------------- .../core/test/security/actionFirewall.test.ts | 38 ---------------- .../core/test/tools/webActionTools.test.ts | 35 --------------- 4 files changed, 146 deletions(-) delete mode 100644 packages/core/src/security/actionFirewall.ts delete mode 100644 packages/core/test/security/actionFirewall.test.ts diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts deleted file mode 100644 index 1a76a45e..00000000 --- a/packages/core/src/security/actionFirewall.ts +++ /dev/null @@ -1,43 +0,0 @@ -export const SECURITY_BLOCKED_CONTEXT_EXFILTRATION = - "Security policy blocked a form fill that appears to contain agent context or prompt data"; - -export type SecurityAssessment = - | { allowed: true } - | { allowed: false; reason: string; isRecoverable: true }; - -export interface FillAssessmentInput { - value: string; -} - -const CONTEXT_EXFILTRATION_PATTERNS = [ - /system prompt/i, - /developer prompt/i, - /conversation history/i, - /tool results?/i, - /page snapshots?/i, - /<\s*external-content\b/i, - /<\/\s*external-content\s*>/i, - /you are an expert at completing tasks using a web browser/i, - /available tools/i, - /mandatory guardrails/i, -]; - -const GENERATED_TEXT_LINE_LIMIT = 2; - -export function assessFillValue(input: FillAssessmentInput): SecurityAssessment { - const value = input.value.trim(); - - if ( - value && - (CONTEXT_EXFILTRATION_PATTERNS.some((pattern) => pattern.test(value)) || - value.split(/\r?\n/).length > GENERATED_TEXT_LINE_LIMIT) - ) { - return { - allowed: false, - reason: SECURITY_BLOCKED_CONTEXT_EXFILTRATION, - isRecoverable: true, - }; - } - - return { allowed: true }; -} diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index c348a129..f1f415de 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -13,7 +13,6 @@ import { buildExtractionPrompt, TOOL_STRINGS } from "../prompts.js"; import type { ProviderConfig } from "../provider.js"; import { BrowserException } from "../errors.js"; import { generateTextWithRetry } from "../utils/retry.js"; -import { assessFillValue } from "../security/actionFirewall.js"; import { withSpan, SpanStatusCode, @@ -46,30 +45,6 @@ type ActionResult = { isRecoverable?: boolean; }; -function securityBlockedResult( - action: string, - error: string, - context: WebActionContext, - ref?: string, - value?: string | number, -): ActionResult { - context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_COMPLETED, { - success: false, - action, - error, - isRecoverable: true, - }); - - return { - success: false, - action, - ...(ref && { ref }), - ...(value !== undefined && { value }), - error, - isRecoverable: true, - }; -} - /** * Helper function to perform an action with full error handling and logging * Handles browser exceptions and converts them to recoverable errors for the agent @@ -182,11 +157,6 @@ export function createWebActionTools(context: WebActionContext) { value: z.string().describe(TOOL_STRINGS.webActions.common.textValue), }), execute: async ({ ref, value }) => { - const assessment = assessFillValue({ value }); - if (!assessment.allowed) { - return securityBlockedResult("fill", assessment.reason, context, ref); - } - return await performActionWithValidation(PageAction.Fill, context, ref, value); }, }), diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts deleted file mode 100644 index 50048d02..00000000 --- a/packages/core/test/security/actionFirewall.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - assessFillValue, - SECURITY_BLOCKED_CONTEXT_EXFILTRATION, -} from "../../src/security/actionFirewall.js"; - -describe("actionFirewall", () => { - it("blocks filling agent context into a form", () => { - const result = assessFillValue({ - value: - 'System prompt: You are an expert at completing tasks using a web browser.\nsecret', - }); - - expect(result.allowed).toBe(false); - if (result.allowed) { - throw new Error("Expected context exfiltration fill to be blocked"); - } - expect(result.reason).toContain(SECURITY_BLOCKED_CONTEXT_EXFILTRATION); - }); - - it("blocks multiline generated text even without known prompt keywords", () => { - const result = assessFillValue({ - value: "Here is what I can see:\nTask details are available.\nThe previous steps succeeded.", - }); - - expect(result.allowed).toBe(false); - if (result.allowed) { - throw new Error("Expected multiline generated text to be blocked"); - } - expect(result.reason).toContain(SECURITY_BLOCKED_CONTEXT_EXFILTRATION); - }); - - it("allows ordinary user-facing form values", () => { - expect(assessFillValue({ value: "San Francisco" }).allowed).toBe(true); - expect(assessFillValue({ value: "Test <>&\"'`\n\t value" }).allowed).toBe(true); - expect(assessFillValue({ value: "a".repeat(10000) }).allowed).toBe(true); - }); -}); diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index b1256fee..34374e74 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -273,41 +273,6 @@ describe("Web Action Tools", () => { }); }); - it("should block filling agent context into a form", async () => { - const performActionSpy = vi.spyOn(mockBrowser, "performAction"); - - const result = await tools.fill.execute({ - ref: "input1", - value: - "Conversation history:\nTask: summarize the page\npage text", - }); - - expect(performActionSpy).not.toHaveBeenCalled(); - expect(result.success).toBe(false); - expect(result.action).toBe("fill"); - expect(result.ref).toBe("input1"); - expect(result.value).toBeUndefined(); - expect(result.isRecoverable).toBe(true); - expect(result.error).toContain("Security policy blocked"); - }); - - it("should block multiline generated text without prompt keywords", async () => { - const performActionSpy = vi.spyOn(mockBrowser, "performAction"); - - const result = await tools.fill.execute({ - ref: "input1", - value: "Here is the current working state:\nThe page loaded.\nThe next action is ready.", - }); - - expect(performActionSpy).not.toHaveBeenCalled(); - expect(result.success).toBe(false); - expect(result.action).toBe("fill"); - expect(result.ref).toBe("input1"); - expect(result.value).toBeUndefined(); - expect(result.isRecoverable).toBe(true); - expect(result.error).toContain("Security policy blocked"); - }); - it("should emit browser action events", async () => { const emitSpy = vi.spyOn(eventEmitter, "emit"); From 80840ad36bdaba109bd6ba7cbfa78d2525503c4b Mon Sep 17 00:00:00 2001 From: sbrooke Date: Tue, 26 May 2026 13:59:51 -0400 Subject: [PATCH 03/44] fix(core): gate form actions by field provenance --- packages/core/src/browser/ariaBrowser.ts | 42 ++++ .../core/src/browser/playwrightBrowser.ts | 185 +++++++++++++++- packages/core/src/core.ts | 7 +- packages/core/src/security/actionFirewall.ts | 140 ++++++++++++ packages/core/src/tools/webActionTools.ts | 117 +++++++++- packages/core/src/webAgent.ts | 67 ++---- .../core/test/security/actionFirewall.test.ts | 149 +++++++++++++ .../core/test/tools/webActionTools.test.ts | 206 +++++++++++++++++- packages/core/test/webAgent.test.ts | 36 ++- .../src/background/ExtensionBrowser.ts | 202 ++++++++++++++++- 10 files changed, 1099 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/security/actionFirewall.ts create mode 100644 packages/core/test/security/actionFirewall.test.ts diff --git a/packages/core/src/browser/ariaBrowser.ts b/packages/core/src/browser/ariaBrowser.ts index ca1abacd..0ec38656 100644 --- a/packages/core/src/browser/ariaBrowser.ts +++ b/packages/core/src/browser/ariaBrowser.ts @@ -57,6 +57,39 @@ export interface TemporaryTab { waitForLoadState(state: LoadState, options?: { timeout?: number }): Promise; } +export interface FieldMetadata { + ref: string; + tagName: string; + inputType: string | null; + role: string | null; + name: string | null; + label: string | null; + placeholder: string | null; + autocomplete: string | null; + isContentEditable: boolean; + formId: string | null; + formAction: string | null; + formMethod: string | null; +} + +export interface FormFieldState { + ref: string | null; + name: string | null; + tagName: string; + inputType: string | null; + autocomplete: string | null; +} + +export interface FormSubmissionContext { + submitterRef: string; + formId: string | null; + actionUrl: string | null; + method: string | null; + fields: FormFieldState[]; +} + +export type FormSubmissionTrigger = "click" | "enter"; + export interface AriaBrowser { /** The name of the browser being used */ browserName: string; @@ -99,6 +132,15 @@ export interface AriaBrowser { */ performAction(ref: string, action: PageAction, value?: string): Promise; + /** Returns structural metadata for an element ref used in form/action policy checks. */ + getFieldMetadata(ref: string): Promise; + + /** Returns the form that would be submitted by activating this ref, if any. */ + getFormSubmissionContext( + ref: string, + trigger?: FormSubmissionTrigger, + ): Promise; + /** * Waits for a specific load state of the page * @param state The load state to wait for diff --git a/packages/core/src/browser/playwrightBrowser.ts b/packages/core/src/browser/playwrightBrowser.ts index 510f2a71..e181d646 100644 --- a/packages/core/src/browser/playwrightBrowser.ts +++ b/packages/core/src/browser/playwrightBrowser.ts @@ -11,7 +11,15 @@ import { Locator, errors as playwrightErrors, } from "playwright"; -import { AriaBrowser, PageAction, LoadState, TemporaryTab } from "./ariaBrowser.js"; +import { + AriaBrowser, + FieldMetadata, + FormSubmissionTrigger, + FormSubmissionContext, + LoadState, + PageAction, + TemporaryTab, +} from "./ariaBrowser.js"; import { PlaywrightBlocker } from "@ghostery/adblocker-playwright"; import fetch from "cross-fetch"; import TurndownService from "turndown"; @@ -788,6 +796,181 @@ export class PlaywrightBrowser implements AriaBrowser { return locator; } + async getFieldMetadata(ref: string): Promise { + const locator = await this.validateElementRef(ref); + + return locator.evaluate((element, elementRef): FieldMetadata => { + const el = element as HTMLElement; + const input = el instanceof HTMLInputElement ? el : null; + const form = getElementForm(el); + + return { + ref: elementRef, + tagName: el.tagName.toLowerCase(), + inputType: input?.type?.toLowerCase() ?? null, + role: el.getAttribute("role"), + name: getElementName(el), + label: getElementLabel(el), + placeholder: getElementPlaceholder(el), + autocomplete: getElementAutocomplete(el), + isContentEditable: el.isContentEditable, + formId: form?.id || null, + formAction: form?.action || null, + formMethod: form?.method?.toLowerCase() || null, + }; + + function getElementForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.form; + } + return node.closest("form"); + } + + function getElementName(node: HTMLElement): string | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.name || null; + } + return node.getAttribute("name"); + } + + function getElementLabel(node: HTMLElement): string | null { + const ariaLabel = node.getAttribute("aria-label"); + if (ariaLabel?.trim()) return ariaLabel.trim(); + + const labelledBy = node.getAttribute("aria-labelledby"); + if (labelledBy) { + const text = labelledBy + .split(/\s+/) + .map((id) => node.ownerDocument.getElementById(id)?.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } + + if ("labels" in node) { + const labels = (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) + .labels; + const text = Array.from(labels || []) + .map((label) => label.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } + + return null; + } + + function getElementPlaceholder(node: HTMLElement): string | null { + if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { + return node.placeholder || null; + } + return null; + } + + function getElementAutocomplete(node: HTMLElement): string | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement + ) { + return node.autocomplete || null; + } + return null; + } + }, ref); + } + + async getFormSubmissionContext( + ref: string, + trigger: FormSubmissionTrigger = "click", + ): Promise { + const locator = await this.validateElementRef(ref); + + return locator.evaluate( + (element, { submitterRef, trigger }): FormSubmissionContext | null => { + const el = element as HTMLElement; + if (!canSubmitForm(el, trigger)) return null; + + const form = getSubmissionForm(el); + if (!form) return null; + + const fields = Array.from(form.elements) + .filter( + (field): field is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement => + field instanceof HTMLInputElement || + field instanceof HTMLTextAreaElement || + field instanceof HTMLSelectElement, + ) + .filter((field) => !field.disabled) + .map((field) => ({ + ref: field.getAttribute("data-pilo-ref"), + name: field.name || null, + tagName: field.tagName.toLowerCase(), + inputType: field instanceof HTMLInputElement ? field.type.toLowerCase() : null, + autocomplete: "autocomplete" in field ? field.autocomplete || null : null, + })); + + return { + submitterRef, + formId: form.id || null, + actionUrl: form.action || null, + method: form.method?.toLowerCase() || null, + fields, + }; + + function getSubmissionForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLButtonElement || + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement + ) { + return node.form; + } + return node.closest("form"); + } + + function canSubmitForm(node: HTMLElement, submitTrigger: FormSubmissionTrigger): boolean { + if (submitTrigger === "click") { + if (node instanceof HTMLButtonElement) { + return node.type === "submit"; + } + if (node instanceof HTMLInputElement) { + return node.type === "submit" || node.type === "image"; + } + return false; + } + + if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) + return false; + if (!(node instanceof HTMLInputElement)) return false; + return ![ + "button", + "checkbox", + "color", + "file", + "hidden", + "radio", + "range", + "reset", + "submit", + ].includes(node.type); + } + }, + { submitterRef: ref, trigger }, + ); + } + async performAction(ref: string, action: PageAction, value?: string): Promise { if (!this.page) throw new Error("Browser not started"); return withSpan( diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 75e4f2c7..e1f7e0d6 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -5,7 +5,12 @@ */ export { WebAgent } from "./webAgent.js"; -export type { AriaBrowser } from "./browser/ariaBrowser.js"; +export type { + AriaBrowser, + FieldMetadata, + FormSubmissionContext, + FormSubmissionTrigger, +} from "./browser/ariaBrowser.js"; export { PageAction, LoadState } from "./browser/ariaBrowser.js"; export type { TaskExecutionResult, TaskError, WebAgentOptions } from "./webAgent.js"; export { TaskErrorCode } from "./webAgent.js"; diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts new file mode 100644 index 00000000..8718a754 --- /dev/null +++ b/packages/core/src/security/actionFirewall.ts @@ -0,0 +1,140 @@ +import type { FieldMetadata, FormSubmissionContext } from "../browser/ariaBrowser.js"; + +export const SECURITY_BLOCKED_UNAUTHORIZED_FILL = + "Security policy blocked filling a submittable form field without user approval"; + +export const SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT = + "Security policy blocked submitting a form containing unauthorized agent-filled data"; + +export type FillSource = "agent" | "user-approved"; + +export type ActionFirewallResult = + | { allowed: true; operational?: boolean } + | { allowed: false; reason: string; isRecoverable: true }; + +const OPERATIONAL_INPUT_TYPES = new Set([ + "search", + "url", + "number", + "date", + "datetime-local", + "month", + "time", + "week", + "color", + "range", +]); + +const OPERATIONAL_ROLES = new Set(["searchbox", "combobox", "spinbutton", "slider"]); + +const SENSITIVE_AUTOCOMPLETE_TOKENS = [ + "name", + "honorific-prefix", + "given-name", + "additional-name", + "family-name", + "honorific-suffix", + "nickname", + "email", + "username", + "new-password", + "current-password", + "one-time-code", + "organization", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "address-level1", + "address-level2", + "address-level3", + "address-level4", + "country", + "country-name", + "postal-code", + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number", + "cc-exp", + "cc-exp-month", + "cc-exp-year", + "cc-csc", + "cc-type", + "transaction-currency", + "transaction-amount", + "language", + "bday", + "bday-day", + "bday-month", + "bday-year", + "sex", + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", + "tel-extension", + "impp", + "url", + "photo", +]; + +export function assessFill(input: { + field: FieldMetadata; + source: FillSource; +}): ActionFirewallResult { + if (input.source === "user-approved") { + return { allowed: true }; + } + + if (isOperationalField(input.field)) { + return { allowed: true, operational: true }; + } + + return { + allowed: false, + reason: SECURITY_BLOCKED_UNAUTHORIZED_FILL, + isRecoverable: true, + }; +} + +export function assessFormSubmission(input: { + form: FormSubmissionContext; + approvedRefs: { has(ref: string): boolean }; + agentFilledRefs: ReadonlySet; + operationalRefs: ReadonlySet; +}): ActionFirewallResult { + for (const field of input.form.fields) { + if (!field.ref || !input.agentFilledRefs.has(field.ref)) continue; + if (input.approvedRefs.has(field.ref) || input.operationalRefs.has(field.ref)) continue; + + return { + allowed: false, + reason: SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, + isRecoverable: true, + }; + } + + return { allowed: true }; +} + +function isOperationalField(field: FieldMetadata): boolean { + const inputType = field.inputType?.toLowerCase() ?? null; + const role = field.role?.toLowerCase() ?? null; + + if (hasSensitiveAutocomplete(field.autocomplete)) return false; + if (field.tagName.toLowerCase() === "textarea" || field.isContentEditable) return false; + if (inputType && OPERATIONAL_INPUT_TYPES.has(inputType)) return true; + if (role && OPERATIONAL_ROLES.has(role)) return true; + return false; +} + +function hasSensitiveAutocomplete(autocomplete: string | null): boolean { + if (!autocomplete) return false; + const tokens = autocomplete.toLowerCase().split(/\s+/); + return tokens.some((token) => SENSITIVE_AUTOCOMPLETE_TOKENS.includes(token)); +} diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index f1f415de..ea1bbb88 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -13,6 +13,7 @@ import { buildExtractionPrompt, TOOL_STRINGS } from "../prompts.js"; import type { ProviderConfig } from "../provider.js"; import { BrowserException } from "../errors.js"; import { generateTextWithRetry } from "../utils/retry.js"; +import { assessFill, assessFormSubmission } from "../security/actionFirewall.js"; import { withSpan, SpanStatusCode, @@ -25,6 +26,9 @@ interface WebActionContext { eventEmitter: WebAgentEventEmitter; providerConfig: ProviderConfig; abortSignal?: AbortSignal; + approvedRefs?: { has(ref: string): boolean }; + agentFilledRefs?: Set; + operationalRefs?: Set; } /** @@ -45,6 +49,86 @@ type ActionResult = { isRecoverable?: boolean; }; +const EMPTY_APPROVED_REFS = { has: () => false }; + +function recoverableBrowserErrorResult( + action: string, + error: BrowserException, + context: WebActionContext, + ref?: string, + value?: string | number, +): ActionResult { + context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_COMPLETED, { + success: false, + action, + error: error.message, + isRecoverable: true, + }); + + return { + success: false, + action, + ...(ref && { ref }), + ...(value !== undefined && { value }), + error: error.message, + isRecoverable: true, + }; +} + +function securityBlockedResult( + action: string, + error: string, + context: WebActionContext, + ref?: string, +): ActionResult { + context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_COMPLETED, { + success: false, + action, + error, + isRecoverable: true, + }); + + return { + success: false, + action, + ...(ref && { ref }), + error, + isRecoverable: true, + }; +} + +async function assessFormSubmissionForAction( + action: PageAction.Click | PageAction.Enter, + context: WebActionContext, + ref: string, +): Promise { + try { + const form = await context.browser.getFormSubmissionContext( + ref, + action === PageAction.Click ? "click" : "enter", + ); + if (!form) return null; + + const assessment = assessFormSubmission({ + form, + approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, + agentFilledRefs: context.agentFilledRefs ?? new Set(), + operationalRefs: context.operationalRefs ?? new Set(), + }); + + if (!assessment.allowed) { + return securityBlockedResult(action, assessment.reason, context, ref); + } + } catch (error) { + if (error instanceof BrowserException) { + return recoverableBrowserErrorResult(action, error, context, ref); + } + throw error; + } + + return null; +} + /** * Helper function to perform an action with full error handling and logging * Handles browser exceptions and converts them to recoverable errors for the agent @@ -146,6 +230,9 @@ export function createWebActionTools(context: WebActionContext) { ref: z.string().describe(TOOL_STRINGS.webActions.common.elementRef), }), execute: async ({ ref }) => { + const blocked = await assessFormSubmissionForAction(PageAction.Click, context, ref); + if (blocked) return blocked; + return await performActionWithValidation(PageAction.Click, context, ref); }, }), @@ -157,7 +244,32 @@ export function createWebActionTools(context: WebActionContext) { value: z.string().describe(TOOL_STRINGS.webActions.common.textValue), }), execute: async ({ ref, value }) => { - return await performActionWithValidation(PageAction.Fill, context, ref, value); + try { + const metadata = await context.browser.getFieldMetadata(ref); + const userApproved = Boolean(context.approvedRefs?.has(ref)); + const assessment = assessFill({ + field: metadata, + source: userApproved ? "user-approved" : "agent", + }); + + if (!assessment.allowed) { + return securityBlockedResult(PageAction.Fill, assessment.reason, context, ref); + } + + const result = await performActionWithValidation(PageAction.Fill, context, ref, value); + if (result.success && !userApproved) { + context.agentFilledRefs?.add(ref); + if (assessment.operational) { + context.operationalRefs?.add(ref); + } + } + return result; + } catch (error) { + if (error instanceof BrowserException) { + return recoverableBrowserErrorResult(PageAction.Fill, error, context, ref); + } + throw error; + } }, }), @@ -218,6 +330,9 @@ export function createWebActionTools(context: WebActionContext) { ref: z.string().describe(TOOL_STRINGS.webActions.common.elementRef), }), execute: async ({ ref }) => { + const blocked = await assessFormSubmissionForAction(PageAction.Enter, context, ref); + if (blocked) return blocked; + return await performActionWithValidation(PageAction.Enter, context, ref); }, }), diff --git a/packages/core/src/webAgent.ts b/packages/core/src/webAgent.ts index 05d68a56..20a2ffce 100644 --- a/packages/core/src/webAgent.ts +++ b/packages/core/src/webAgent.ts @@ -44,7 +44,7 @@ import { SearchService } from "./search/searchService.js"; import { createPlanningTools } from "./tools/planningTools.js"; import { createValidationTools } from "./tools/validationTools.js"; import { createTabstackTools } from "./tools/tabstackTools.js"; -import { createInteractiveTools, ApprovedRefs, FILL_GATE_ERROR } from "./tools/interactiveTools.js"; +import { createInteractiveTools, ApprovedRefs } from "./tools/interactiveTools.js"; import { createTabstackClient } from "./tabstack/client.js"; import type { UserDataCallback } from "./types/interactive.js"; import { nanoid } from "nanoid"; @@ -388,12 +388,30 @@ export class WebAgent { task: string, executionState: ExecutionState, ): Promise<{ success: boolean; finalAnswer: string | null; error?: TaskError }> { + // Only include interactive tools if a callback is provided + let interactiveToolSet: Record = {}; + let approvedRefs: ApprovedRefs | null = null; + const agentFilledRefs = new Set(); + const operationalRefs = new Set(); + if (this.onUserDataRequired) { + const result = createInteractiveTools({ + callback: this.onUserDataRequired, + browser: this.browser, + eventEmitter: this.eventEmitter, + }); + interactiveToolSet = result.tools; + approvedRefs = result.approvedRefs; + } + // Setup tools once const webActionTools = createWebActionTools({ browser: this.browser, eventEmitter: this.eventEmitter, providerConfig: this.providerConfig, abortSignal: this.abortSignal, + approvedRefs: approvedRefs ?? undefined, + agentFilledRefs, + operationalRefs, }); // Only include search tools if a search service was created @@ -409,51 +427,6 @@ export class WebAgent { }) : {}; - // Only include interactive tools if a callback is provided - let interactiveToolSet: Record = {}; - let approvedRefs: ApprovedRefs | null = null; - if (this.onUserDataRequired) { - const result = createInteractiveTools({ - callback: this.onUserDataRequired, - browser: this.browser, - eventEmitter: this.eventEmitter, - }); - interactiveToolSet = result.tools; - approvedRefs = result.approvedRefs; - } - - // When interactive mode is on, gate fill/select/check to require approved refs. - // On first unapproved attempt, return an error. If the agent retries the same ref - // (indicating it's a navigation/search field, not a user-data form field), allow it - // through on the second attempt to avoid a deadlock. - if (approvedRefs) { - const warnedRefs = new Set(); - const gatedActions = ["fill", "select", "check"] as const; - for (const actionName of gatedActions) { - const originalTool = webActionTools[actionName]; - if (originalTool) { - const originalExecute = originalTool.execute!; - (originalTool as any).execute = async (args: any, options: any) => { - if (args.ref && !approvedRefs!.has(args.ref)) { - if (!warnedRefs.has(args.ref)) { - // First attempt: warn and block - warnedRefs.add(args.ref); - return { - success: false, - action: actionName, - ref: args.ref, - error: FILL_GATE_ERROR, - isRecoverable: true, - }; - } - // Second attempt: agent confirmed this is a navigation/search field, allow it - } - return originalExecute(args, options); - }; - } - } - } - // Merge all tools const allTools = { ...webActionTools, ...searchTools, ...tabstackTools, ...interactiveToolSet }; @@ -510,6 +483,8 @@ export class WebAgent { if (approvedRefs) { approvedRefs.clear(); } + agentFilledRefs.clear(); + operationalRefs.clear(); await this.addPageSnapshot(); } diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts new file mode 100644 index 00000000..ad4fa50e --- /dev/null +++ b/packages/core/test/security/actionFirewall.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import type { FieldMetadata, FormSubmissionContext } from "../../src/browser/ariaBrowser.js"; +import { + assessFill, + assessFormSubmission, + SECURITY_BLOCKED_UNAUTHORIZED_FILL, + SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, +} from "../../src/security/actionFirewall.js"; + +function field(overrides: Partial = {}): FieldMetadata { + return { + ref: "E1", + tagName: "input", + inputType: "text", + role: null, + name: null, + label: null, + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: "form-1", + formAction: "https://example.com/search", + formMethod: "get", + ...overrides, + }; +} + +function form(overrides: Partial = {}): FormSubmissionContext { + return { + submitterRef: "E9", + formId: "form-1", + actionUrl: "https://example.com/submit", + method: "post", + fields: [], + ...overrides, + }; +} + +describe("actionFirewall", () => { + it("allows agent fills for operational search fields", () => { + const result = assessFill({ + field: field({ inputType: "search", label: "Search products" }), + source: "agent", + }); + + expect(result.allowed).toBe(true); + if (!result.allowed) throw new Error("Expected fill to be allowed"); + expect(result.operational).toBe(true); + }); + + it("blocks agent fills for freeform text fields", () => { + const result = assessFill({ + field: field({ label: "Message" }), + source: "agent", + }); + + expect(result.allowed).toBe(false); + if (result.allowed) throw new Error("Expected fill to be blocked"); + expect(result.reason).toBe(SECURITY_BLOCKED_UNAUTHORIZED_FILL); + }); + + it("does not classify fields as operational from label text alone", () => { + const result = assessFill({ + field: field({ inputType: "text", label: "Search products", placeholder: "Search" }), + source: "agent", + }); + + expect(result.allowed).toBe(false); + }); + + it("blocks inherently freeform fields even when they have operational roles", () => { + const result = assessFill({ + field: field({ tagName: "textarea", inputType: null, role: "searchbox" }), + source: "agent", + }); + + expect(result.allowed).toBe(false); + }); + + it("blocks fields with sensitive autocomplete even when the input type looks operational", () => { + const result = assessFill({ + field: field({ inputType: "url", autocomplete: "url" }), + source: "agent", + }); + + expect(result.allowed).toBe(false); + }); + + it("allows user-approved freeform fields", () => { + const result = assessFill({ + field: field({ label: "Message" }), + source: "user-approved", + }); + + expect(result.allowed).toBe(true); + }); + + it("blocks submitting forms with unauthorized agent-filled fields", () => { + const result = assessFormSubmission({ + form: form({ + fields: [ + { + ref: "E1", + name: "message", + tagName: "textarea", + inputType: null, + autocomplete: null, + }, + ], + }), + approvedRefs: new Set(), + agentFilledRefs: new Set(["E1"]), + operationalRefs: new Set(), + }); + + expect(result.allowed).toBe(false); + if (result.allowed) throw new Error("Expected submit to be blocked"); + expect(result.reason).toBe(SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT); + expect(result.reason).not.toContain("do not leak this value"); + }); + + it("allows submitting forms when agent-filled fields are approved or operational", () => { + const result = assessFormSubmission({ + form: form({ + fields: [ + { + ref: "E1", + name: "q", + tagName: "input", + inputType: "search", + autocomplete: null, + }, + { + ref: "E2", + name: "email", + tagName: "input", + inputType: "email", + autocomplete: "email", + }, + ], + }), + approvedRefs: new Set(["E2"]), + agentFilledRefs: new Set(["E1", "E2"]), + operationalRefs: new Set(["E1"]), + }); + + expect(result.allowed).toBe(true); + }); +}); diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index 34374e74..94a9213c 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createWebActionTools } from "../../src/tools/webActionTools.js"; -import { AriaBrowser, PageAction } from "../../src/browser/ariaBrowser.js"; +import { + AriaBrowser, + FieldMetadata, + FormSubmissionTrigger, + FormSubmissionContext, + PageAction, +} from "../../src/browser/ariaBrowser.js"; import { WebAgentEventEmitter, WebAgentEventType } from "../../src/events.js"; import { LanguageModel } from "ai"; import { z } from "zod"; @@ -30,6 +36,8 @@ class MockBrowser implements AriaBrowser { browserName = "mock-browser"; public url = "https://example.com"; public title = "Example Page"; + public fieldMetadata = new Map(); + public formSubmissionContexts = new Map(); async start(): Promise {} async shutdown(): Promise {} @@ -73,6 +81,32 @@ class MockBrowser implements AriaBrowser { // Mock implementation - can be configured to throw errors for testing } + async getFieldMetadata(ref: string): Promise { + return ( + this.fieldMetadata.get(ref) ?? { + ref, + tagName: "input", + inputType: "search", + role: "searchbox", + name: "q", + label: "Search", + placeholder: "Search", + autocomplete: null, + isContentEditable: false, + formId: "search-form", + formAction: "https://example.com/search", + formMethod: "get", + } + ); + } + + async getFormSubmissionContext( + ref: string, + _trigger?: FormSubmissionTrigger, + ): Promise { + return this.formSubmissionContexts.get(ref) ?? null; + } + async waitForLoadState(): Promise {} async runInTemporaryTab(fn: (tab: any) => Promise): Promise { @@ -273,6 +307,76 @@ describe("Web Action Tools", () => { }); }); + it("should block agent fill of freeform submittable fields", async () => { + mockBrowser.fieldMetadata.set("input1", { + ref: "input1", + tagName: "textarea", + inputType: null, + role: null, + name: "message", + label: "Message", + placeholder: "Message", + autocomplete: null, + isContentEditable: false, + formId: "contact", + formAction: "https://example.com/contact", + formMethod: "post", + }); + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + + const result = await tools.fill.execute({ ref: "input1", value: "generated payload" }); + + expect(performActionSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + action: "fill", + ref: "input1", + error: "Security policy blocked filling a submittable form field without user approval", + isRecoverable: true, + }); + expect(result.value).toBeUndefined(); + }); + + it("should allow approved freeform field fills", async () => { + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + mockBrowser.fieldMetadata.set("input1", { + ref: "input1", + tagName: "textarea", + inputType: null, + role: null, + name: "message", + label: "Message", + placeholder: "Message", + autocomplete: null, + isContentEditable: false, + formId: "contact", + formAction: "https://example.com/contact", + formMethod: "post", + }); + context.approvedRefs = new Set(["input1"]); + tools = createWebActionTools(context); + + const result = await tools.fill.execute({ ref: "input1", value: "user-provided value" }); + + expect(performActionSpy).toHaveBeenCalledWith( + "input1", + PageAction.Fill, + "user-provided value", + ); + expect(result.success).toBe(true); + }); + + it("should track agent-filled operational refs", async () => { + context.agentFilledRefs = new Set(); + context.operationalRefs = new Set(); + tools = createWebActionTools(context); + + await tools.fill.execute({ ref: "input1", value: "pilo" }); + + expect(context.agentFilledRefs.has("input1")).toBe(true); + expect(context.operationalRefs.has("input1")).toBe(true); + }); + it("should emit browser action events", async () => { const emitSpy = vi.spyOn(eventEmitter, "emit"); @@ -509,6 +613,106 @@ describe("Web Action Tools", () => { expect(invalid.success).toBe(false); }); + it("should block click submit when form contains unauthorized agent-filled values", async () => { + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + context.agentFilledRefs = new Set(["message"]); + context.operationalRefs = new Set(); + context.approvedRefs = new Set(); + mockBrowser.formSubmissionContexts.set("submit1", { + submitterRef: "submit1", + formId: "contact", + actionUrl: "https://example.com/contact", + method: "post", + fields: [ + { + ref: "message", + name: "message", + tagName: "textarea", + inputType: null, + autocomplete: null, + }, + ], + }); + tools = createWebActionTools(context); + + const result = await tools.click.execute({ ref: "submit1" }); + + expect(performActionSpy).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.error).toBe( + "Security policy blocked submitting a form containing unauthorized agent-filled data", + ); + expect(JSON.stringify(result)).not.toContain("generated payload"); + }); + + it("should allow click submit when form fields are approved or operational", async () => { + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + context.agentFilledRefs = new Set(["query", "email"]); + context.operationalRefs = new Set(["query"]); + context.approvedRefs = new Set(["email"]); + mockBrowser.formSubmissionContexts.set("submit1", { + submitterRef: "submit1", + formId: "search", + actionUrl: "https://example.com/search", + method: "get", + fields: [ + { + ref: "query", + name: "q", + tagName: "input", + inputType: "search", + autocomplete: null, + }, + { + ref: "email", + name: "email", + tagName: "input", + inputType: "email", + autocomplete: "email", + }, + ], + }); + tools = createWebActionTools(context); + + const result = await tools.click.execute({ ref: "submit1" }); + + expect(performActionSpy).toHaveBeenCalledWith("submit1", PageAction.Click, undefined); + expect(result.success).toBe(true); + }); + + it("should block enter submit when form contains unauthorized agent-filled fields", async () => { + const formContextSpy = vi.spyOn(mockBrowser, "getFormSubmissionContext"); + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + context.agentFilledRefs = new Set(["message"]); + context.operationalRefs = new Set(); + context.approvedRefs = new Set(); + mockBrowser.formSubmissionContexts.set("input1", { + submitterRef: "input1", + formId: "contact", + actionUrl: "https://example.com/contact", + method: "post", + fields: [ + { + ref: "message", + name: "message", + tagName: "textarea", + inputType: null, + autocomplete: null, + }, + ], + }); + tools = createWebActionTools(context); + + const result = await tools.enter.execute({ ref: "input1" }); + + expect(formContextSpy).toHaveBeenCalledWith("input1", "enter"); + expect(performActionSpy).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.error).toBe( + "Security policy blocked submitting a form containing unauthorized agent-filled data", + ); + }); + it("should execute back action successfully", async () => { const performActionSpy = vi.spyOn(mockBrowser, "performAction"); diff --git a/packages/core/test/webAgent.test.ts b/packages/core/test/webAgent.test.ts index 421456b2..afed888a 100644 --- a/packages/core/test/webAgent.test.ts +++ b/packages/core/test/webAgent.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { WebAgent, WebAgentOptions } from "../src/webAgent.js"; -import { AriaBrowser, PageAction } from "../src/browser/ariaBrowser.js"; +import { + AriaBrowser, + FieldMetadata, + FormSubmissionTrigger, + FormSubmissionContext, + PageAction, +} from "../src/browser/ariaBrowser.js"; import { WebAgentEventEmitter, WebAgentEventType } from "../src/events.js"; import { LanguageModel, streamText } from "ai"; import { Logger } from "../src/loggers/types.js"; @@ -152,6 +158,8 @@ class MockBrowser implements AriaBrowser { `; private markdown = "# Mock Page\nContent here"; + fieldMetadata = new Map(); + formSubmissionContexts = new Map(); async start(): Promise {} async shutdown(): Promise {} @@ -191,6 +199,32 @@ class MockBrowser implements AriaBrowser { async performAction(_ref: string, _action: PageAction, _value?: string): Promise {} + async getFieldMetadata(ref: string): Promise { + return ( + this.fieldMetadata.get(ref) ?? { + ref, + tagName: "input", + inputType: "search", + role: "searchbox", + name: "q", + label: "Search", + placeholder: "Search", + autocomplete: null, + isContentEditable: false, + formId: "search-form", + formAction: "https://example.com/search", + formMethod: "get", + } + ); + } + + async getFormSubmissionContext( + ref: string, + _trigger?: FormSubmissionTrigger, + ): Promise { + return this.formSubmissionContexts.get(ref) ?? null; + } + async waitForLoadState(): Promise {} async runInTemporaryTab(fn: (tab: any) => Promise): Promise { diff --git a/packages/extension/src/background/ExtensionBrowser.ts b/packages/extension/src/background/ExtensionBrowser.ts index a3ad81c5..db4920ab 100644 --- a/packages/extension/src/background/ExtensionBrowser.ts +++ b/packages/extension/src/background/ExtensionBrowser.ts @@ -1,5 +1,10 @@ import browser from "webextension-polyfill"; -import type { AriaBrowser } from "pilo-core/core"; +import type { + AriaBrowser, + FieldMetadata, + FormSubmissionContext, + FormSubmissionTrigger, +} from "pilo-core/core"; import { PageAction, LoadState } from "pilo-core/core"; import type { Tabs } from "webextension-polyfill"; import { createLogger } from "../shared/utils/logger"; @@ -302,6 +307,201 @@ export class ExtensionBrowser implements AriaBrowser { } } + async getFieldMetadata(ref: string): Promise { + const tab = await this.getActiveTab(); + await this.ensureContentScript(); + + const [{ result }] = await browser.scripting.executeScript({ + target: { tabId: tab.id! }, + func: (elementRef: string) => { + const element = document.querySelector(`[data-pilo-ref="${elementRef}"]`); + if (!(element instanceof HTMLElement)) { + throw new Error(`Element with ref ${elementRef} not found in DOM`); + } + + const input = element instanceof HTMLInputElement ? element : null; + const form = getElementForm(element); + + return { + ref: elementRef, + tagName: element.tagName.toLowerCase(), + inputType: input?.type?.toLowerCase() ?? null, + role: element.getAttribute("role"), + name: getElementName(element), + label: getElementLabel(element), + placeholder: getElementPlaceholder(element), + autocomplete: getElementAutocomplete(element), + isContentEditable: element.isContentEditable, + formId: form?.id || null, + formAction: form?.action || null, + formMethod: form?.method?.toLowerCase() || null, + }; + + function getElementForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.form; + } + return node.closest("form"); + } + + function getElementName(node: HTMLElement): string | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.name || null; + } + return node.getAttribute("name"); + } + + function getElementLabel(node: HTMLElement): string | null { + const ariaLabel = node.getAttribute("aria-label"); + if (ariaLabel?.trim()) return ariaLabel.trim(); + + const labelledBy = node.getAttribute("aria-labelledby"); + if (labelledBy) { + const text = labelledBy + .split(/\s+/) + .map((id) => node.ownerDocument.getElementById(id)?.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } + + if ("labels" in node) { + const labels = (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) + .labels; + const text = Array.from(labels || []) + .map((label) => label.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } + + return null; + } + + function getElementPlaceholder(node: HTMLElement): string | null { + if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { + return node.placeholder || null; + } + return null; + } + + function getElementAutocomplete(node: HTMLElement): string | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement + ) { + return node.autocomplete || null; + } + return null; + } + }, + args: [ref], + }); + + return result as FieldMetadata; + } + + async getFormSubmissionContext( + ref: string, + trigger: FormSubmissionTrigger = "click", + ): Promise { + const tab = await this.getActiveTab(); + await this.ensureContentScript(); + + const [{ result }] = await browser.scripting.executeScript({ + target: { tabId: tab.id! }, + func: (paramsJson: string) => { + const { ref: submitterRef, trigger: submitTrigger } = JSON.parse(paramsJson) as { + ref: string; + trigger: FormSubmissionTrigger; + }; + const element = document.querySelector(`[data-pilo-ref="${submitterRef}"]`); + if (!(element instanceof HTMLElement)) { + throw new Error(`Element with ref ${submitterRef} not found in DOM`); + } + if (!canSubmitForm(element, submitTrigger)) return null; + + const form = getSubmissionForm(element); + if (!form) return null; + + const fields = Array.from(form.elements) + .filter( + (field): field is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement => + field instanceof HTMLInputElement || + field instanceof HTMLTextAreaElement || + field instanceof HTMLSelectElement, + ) + .filter((field) => !field.disabled) + .map((field) => ({ + ref: field.getAttribute("data-pilo-ref"), + name: field.name || null, + tagName: field.tagName.toLowerCase(), + inputType: field instanceof HTMLInputElement ? field.type.toLowerCase() : null, + autocomplete: "autocomplete" in field ? field.autocomplete || null : null, + })); + + return { + submitterRef, + formId: form.id || null, + actionUrl: form.action || null, + method: form.method?.toLowerCase() || null, + fields, + }; + + function getSubmissionForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLButtonElement || + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement + ) { + return node.form; + } + return node.closest("form"); + } + + function canSubmitForm(node: HTMLElement, submitTrigger: FormSubmissionTrigger): boolean { + if (submitTrigger === "click") { + if (node instanceof HTMLButtonElement) return node.type === "submit"; + if (node instanceof HTMLInputElement) { + return node.type === "submit" || node.type === "image"; + } + return false; + } + + if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) + return false; + if (!(node instanceof HTMLInputElement)) return false; + return ![ + "button", + "checkbox", + "color", + "file", + "hidden", + "radio", + "range", + "reset", + "submit", + ].includes(node.type); + } + }, + args: [JSON.stringify({ ref, trigger })], + }); + + return result as FormSubmissionContext | null; + } + async performAction(ref: string, action: PageAction, value?: string): Promise { console.log( `ExtensionBrowser: performAction() called with ref: ${ref}, action: ${action}, value: ${value}`, From 1c8e6014ff3862b05d0afaaed9ee4de62b3361e0 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Tue, 26 May 2026 14:32:22 -0400 Subject: [PATCH 04/44] fix(core): require action provenance tracking --- packages/core/src/tools/webActionTools.ts | 16 ++++++++++------ packages/core/test/tools/webActionTools.test.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index ea1bbb88..6a9dcd1a 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -27,8 +27,8 @@ interface WebActionContext { providerConfig: ProviderConfig; abortSignal?: AbortSignal; approvedRefs?: { has(ref: string): boolean }; - agentFilledRefs?: Set; - operationalRefs?: Set; + agentFilledRefs: Set; + operationalRefs: Set; } /** @@ -112,8 +112,8 @@ async function assessFormSubmissionForAction( const assessment = assessFormSubmission({ form, approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, - agentFilledRefs: context.agentFilledRefs ?? new Set(), - operationalRefs: context.operationalRefs ?? new Set(), + agentFilledRefs: context.agentFilledRefs, + operationalRefs: context.operationalRefs, }); if (!assessment.allowed) { @@ -223,6 +223,10 @@ async function performActionWithValidation( } export function createWebActionTools(context: WebActionContext) { + if (!context.agentFilledRefs || !context.operationalRefs) { + throw new Error("Web action provenance tracking sets are required"); + } + return { click: tool({ description: TOOL_STRINGS.webActions.click.description, @@ -258,9 +262,9 @@ export function createWebActionTools(context: WebActionContext) { const result = await performActionWithValidation(PageAction.Fill, context, ref, value); if (result.success && !userApproved) { - context.agentFilledRefs?.add(ref); + context.agentFilledRefs.add(ref); if (assessment.operational) { - context.operationalRefs?.add(ref); + context.operationalRefs.add(ref); } } return result; diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index 94a9213c..bae69ca0 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -139,6 +139,8 @@ describe("Web Action Tools", () => { eventEmitter, providerConfig: { model: mockProvider }, abortSignal: undefined, + agentFilledRefs: new Set(), + operationalRefs: new Set(), }; tools = createWebActionTools(context); @@ -149,6 +151,16 @@ describe("Web Action Tools", () => { }); describe("Tool Structure", () => { + it("should require provenance tracking sets", () => { + expect(() => + createWebActionTools({ + browser: mockBrowser, + eventEmitter, + providerConfig: { model: mockProvider }, + } as any), + ).toThrow("Web action provenance tracking sets are required"); + }); + it("should create all expected tools", () => { expect(tools).toBeDefined(); expect(tools.click).toBeDefined(); From 55c48eb1a7c274994fb963eee8dc9b55bf5f59c5 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Tue, 26 May 2026 14:43:01 -0400 Subject: [PATCH 05/44] fix(core): preserve form refs after fill actions --- packages/core/src/webAgent.ts | 9 +++- packages/core/test/webAgent.test.ts | 71 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/core/src/webAgent.ts b/packages/core/src/webAgent.ts index 20a2ffce..2da86138 100644 --- a/packages/core/src/webAgent.ts +++ b/packages/core/src/webAgent.ts @@ -1045,8 +1045,7 @@ export class WebAgent { throw new Error(actionOutput.error); } - // Determine if page changed (most actions change the page, except extract and webSearch) - const pageChanged = actionOutput.action !== "extract" && actionOutput.action !== "webSearch"; + const pageChanged = WebAgent.shouldRefreshPageSnapshotAfterAction(actionOutput.action); // Check for terminal actions if (actionOutput.isTerminal) { @@ -1114,6 +1113,12 @@ export class WebAgent { }; } + private static readonly ACTIONS_WITHOUT_PAGE_REFRESH = new Set(["extract", "webSearch", "fill"]); + + private static shouldRefreshPageSnapshotAfterAction(action: string): boolean { + return !WebAgent.ACTIONS_WITHOUT_PAGE_REFRESH.has(action); + } + /** * Check for repeated actions and handle accordingly * @returns Action result if intervention is needed, null otherwise diff --git a/packages/core/test/webAgent.test.ts b/packages/core/test/webAgent.test.ts index afed888a..0bf0ec12 100644 --- a/packages/core/test/webAgent.test.ts +++ b/packages/core/test/webAgent.test.ts @@ -897,6 +897,77 @@ describe("WebAgent", () => { expect(navigatedEvent?.data.url).toBe(startingUrl); }); + it("should keep the same snapshot after fill so form refs remain valid for submit", async () => { + mockGenerateTextWithRetry.mockResolvedValueOnce({ + text: "Planning", + toolResults: [ + { + type: "tool-result", + toolCallId: "plan_1", + toolName: "create_plan", + output: { + successCriteria: "Fill then submit", + plan: "1. Fill the form\n2. Submit the form", + }, + }, + ], + } as any); + + const snapshotSpy = vi.spyOn(mockBrowser, "getTreeWithRefs"); + + mockStreamText.mockReturnValueOnce( + createMockStreamResponse({ + text: "Fill", + toolResults: [ + { + type: "tool-result", + toolCallId: "fill_1", + toolName: "fill", + input: { ref: "input1", value: "context" }, + output: { + success: true, + action: "fill", + ref: "input1", + value: "context", + }, + }, + ], + response: { + messages: [{ role: "assistant", content: "Fill" }], + }, + }) as any, + ); + + mockStreamText.mockReturnValueOnce( + createMockStreamResponse({ + text: "Done", + toolResults: [ + { + type: "tool-result", + toolCallId: "done_1", + toolName: "done", + input: { result: "Complete" }, + output: { + success: true, + action: "done", + result: "Complete", + isTerminal: true, + }, + }, + ], + response: { + messages: [{ role: "assistant", content: "Done" }], + }, + }) as any, + ); + + mockGenerateTextWithRetry.mockResolvedValueOnce(mockValidationResponse("complete")); + + await webAgent.execute("Fill then submit", { startingUrl: "https://example.com" }); + + expect(snapshotSpy).toHaveBeenCalledTimes(1); + }); + it("should pass webSearchEnabled to planning prompt when search provider is set", async () => { // Create a WebAgent with a search provider enabled const searchAgent = new WebAgent(mockBrowser, { From 09650d5a8fde780b749c0eb5df5ae096aed0143f Mon Sep 17 00:00:00 2001 From: sbrooke Date: Tue, 26 May 2026 14:49:14 -0400 Subject: [PATCH 06/44] refactor(core): simplify action firewall helpers --- packages/core/src/security/actionFirewall.ts | 8 ++-- packages/core/src/tools/interactiveTools.ts | 16 +------- packages/core/src/tools/webActionTools.ts | 40 +++++--------------- 3 files changed, 14 insertions(+), 50 deletions(-) diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts index 8718a754..ccbce00b 100644 --- a/packages/core/src/security/actionFirewall.ts +++ b/packages/core/src/security/actionFirewall.ts @@ -27,7 +27,7 @@ const OPERATIONAL_INPUT_TYPES = new Set([ const OPERATIONAL_ROLES = new Set(["searchbox", "combobox", "spinbutton", "slider"]); -const SENSITIVE_AUTOCOMPLETE_TOKENS = [ +const SENSITIVE_AUTOCOMPLETE_TOKENS = new Set([ "name", "honorific-prefix", "given-name", @@ -81,7 +81,7 @@ const SENSITIVE_AUTOCOMPLETE_TOKENS = [ "impp", "url", "photo", -]; +]); export function assessFill(input: { field: FieldMetadata; @@ -104,7 +104,7 @@ export function assessFill(input: { export function assessFormSubmission(input: { form: FormSubmissionContext; - approvedRefs: { has(ref: string): boolean }; + approvedRefs: ReadonlySet; agentFilledRefs: ReadonlySet; operationalRefs: ReadonlySet; }): ActionFirewallResult { @@ -136,5 +136,5 @@ function isOperationalField(field: FieldMetadata): boolean { function hasSensitiveAutocomplete(autocomplete: string | null): boolean { if (!autocomplete) return false; const tokens = autocomplete.toLowerCase().split(/\s+/); - return tokens.some((token) => SENSITIVE_AUTOCOMPLETE_TOKENS.includes(token)); + return tokens.some((token) => SENSITIVE_AUTOCOMPLETE_TOKENS.has(token)); } diff --git a/packages/core/src/tools/interactiveTools.ts b/packages/core/src/tools/interactiveTools.ts index 9f215e18..4869c679 100644 --- a/packages/core/src/tools/interactiveTools.ts +++ b/packages/core/src/tools/interactiveTools.ts @@ -26,21 +26,7 @@ interface InteractiveToolContext { * Used by the fill gate to prevent the agent from filling form fields with * generated data when interactive mode is on. */ -export class ApprovedRefs { - private refs = new Set(); - - add(ref: string): void { - this.refs.add(ref); - } - - has(ref: string): boolean { - return this.refs.has(ref); - } - - clear(): void { - this.refs.clear(); - } -} +export class ApprovedRefs extends Set {} /** * Maps field types from the request schema to the appropriate browser action. diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index 6a9dcd1a..9e8f113d 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -26,7 +26,7 @@ interface WebActionContext { eventEmitter: WebAgentEventEmitter; providerConfig: ProviderConfig; abortSignal?: AbortSignal; - approvedRefs?: { has(ref: string): boolean }; + approvedRefs?: ReadonlySet; agentFilledRefs: Set; operationalRefs: Set; } @@ -49,37 +49,14 @@ type ActionResult = { isRecoverable?: boolean; }; -const EMPTY_APPROVED_REFS = { has: () => false }; +const EMPTY_APPROVED_REFS = new Set(); -function recoverableBrowserErrorResult( - action: string, - error: BrowserException, - context: WebActionContext, - ref?: string, - value?: string | number, -): ActionResult { - context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_COMPLETED, { - success: false, - action, - error: error.message, - isRecoverable: true, - }); - - return { - success: false, - action, - ...(ref && { ref }), - ...(value !== undefined && { value }), - error: error.message, - isRecoverable: true, - }; -} - -function securityBlockedResult( +function failedActionResult( action: string, error: string, context: WebActionContext, ref?: string, + value?: string | number, ): ActionResult { context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_COMPLETED, { success: false, @@ -92,6 +69,7 @@ function securityBlockedResult( success: false, action, ...(ref && { ref }), + ...(value !== undefined && { value }), error, isRecoverable: true, }; @@ -117,11 +95,11 @@ async function assessFormSubmissionForAction( }); if (!assessment.allowed) { - return securityBlockedResult(action, assessment.reason, context, ref); + return failedActionResult(action, assessment.reason, context, ref); } } catch (error) { if (error instanceof BrowserException) { - return recoverableBrowserErrorResult(action, error, context, ref); + return failedActionResult(action, error.message, context, ref); } throw error; } @@ -257,7 +235,7 @@ export function createWebActionTools(context: WebActionContext) { }); if (!assessment.allowed) { - return securityBlockedResult(PageAction.Fill, assessment.reason, context, ref); + return failedActionResult(PageAction.Fill, assessment.reason, context, ref); } const result = await performActionWithValidation(PageAction.Fill, context, ref, value); @@ -270,7 +248,7 @@ export function createWebActionTools(context: WebActionContext) { return result; } catch (error) { if (error instanceof BrowserException) { - return recoverableBrowserErrorResult(PageAction.Fill, error, context, ref); + return failedActionResult(PageAction.Fill, error.message, context, ref); } throw error; } From b0124aa3cb076b79edc21bc69e0970ac798d3d2a Mon Sep 17 00:00:00 2001 From: sbrooke Date: Tue, 26 May 2026 14:58:18 -0400 Subject: [PATCH 07/44] docs(core): document action firewall state invariants --- packages/core/src/webAgent.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/webAgent.ts b/packages/core/src/webAgent.ts index 2da86138..f751d177 100644 --- a/packages/core/src/webAgent.ts +++ b/packages/core/src/webAgent.ts @@ -480,6 +480,8 @@ export class WebAgent { if (needsPageSnapshot) { // Clear approved refs when page changes: ARIA refs reset on each snapshot, // so old ref strings may now point to different DOM elements. + // Recoverable blocked action errors deliberately keep needsPageSnapshot=false + // so a blocked submit retry remains tied to the same agent-filled refs. if (approvedRefs) { approvedRefs.clear(); } @@ -1113,6 +1115,9 @@ export class WebAgent { }; } + // Fill keeps the current snapshot so refs and agent-filled provenance remain + // valid for a following submit check. This trades off immediate visibility + // into dynamic validation UI until a later action refreshes the snapshot. private static readonly ACTIONS_WITHOUT_PAGE_REFRESH = new Set(["extract", "webSearch", "fill"]); private static shouldRefreshPageSnapshotAfterAction(action: string): boolean { From 3af846e904f7a664723afda4ccf2a4c6dc61e0e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:25:38 -0700 Subject: [PATCH 08/44] build(deps): bump the aisdk group with 5 updates (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the aisdk group with 5 updates: | Package | From | To | | --- | --- | --- | | [@ai-sdk/google](https://github.com/vercel/ai/tree/HEAD/packages/google) | `3.0.67` | `3.0.72` | | [@ai-sdk/google-vertex](https://github.com/vercel/ai/tree/HEAD/packages/google-vertex) | `4.0.121` | `4.0.126` | | [@ai-sdk/openai](https://github.com/vercel/ai/tree/HEAD/packages/openai) | `3.0.61` | `3.0.63` | | [@ai-sdk/openai-compatible](https://github.com/vercel/ai/tree/HEAD/packages/openai-compatible) | `2.0.46` | `2.0.47` | | [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) | `6.0.175` | `6.0.177` | Updates `@ai-sdk/google` from 3.0.67 to 3.0.72
Changelog

Sourced from @​ai-sdk/google's changelog.

3.0.72

Patch Changes

  • b3642fe: feat(provider/google): support cancelling long-running Interactions API agents via AbortSignal, and process their intermittent stream

3.0.71

Patch Changes

  • 59530cf: fix(google): emit Vertex no-args streaming tool calls and preserve thoughtSignature

    Vertex emits a no-args function call as a single chunk shaped { functionCall: { name: 'X' } } with no args, no partialArgs, and no willContinue. The streaming parser had no branch for this shape, so the call was dropped along with any thoughtSignature it carried. For Gemini 3 thinking models this caused the next multi-turn step to 400 with missing thought_signature. The unary (doGenerate) path had the same drop.

    Both paths now emit the call as a complete tool call with '{}' input and propagate thoughtSignature provider metadata.

    Fixes #14847.

3.0.70

Patch Changes

  • 4f3f564: fix(provider/google): fix lack of image consistency when using Interactions API in stateless mode

3.0.69

Patch Changes

  • bb377ba: fix(google): omit passing includeServerSideToolInvocations for Vertex tool_config
  • Updated dependencies [f591416]
    • @​ai-sdk/provider-utils@​4.0.27

3.0.68

Patch Changes

  • e0f8c9e: feat(provider/google): add support for the Gemini Interactions API
Commits
  • d5bbdbc Version Packages (#15174)
  • b3642fe Backport: feat(provider/google): support cancelling long-running Interactions...
  • e70aab9 Version Packages (#15138)
  • 59530cf Backport: fix(google): emit no-args streaming tool calls and preserve thought...
  • 0288286 Version Packages (#15105)
  • e3ccdb5 Version Packages (#15094)
  • 4f3f564 Backport: fix(provider/google): fix lack of image consistency when using Inte...
  • bb377ba Backport: fix(google): omit passing includeServerSideToolInvocations for Vert...
  • 3a6aef7 Version Packages (#15072)
  • e0f8c9e Backport: feat(provider/google): add support for the Gemini Interactions API ...
  • See full diff in compare view

Updates `@ai-sdk/google-vertex` from 4.0.121 to 4.0.126
Changelog

Sourced from @​ai-sdk/google-vertex's changelog.

4.0.126

Patch Changes

  • Updated dependencies [b3642fe]
    • @​ai-sdk/google@​3.0.72

4.0.125

Patch Changes

  • Updated dependencies [59530cf]
    • @​ai-sdk/google@​3.0.71

4.0.124

Patch Changes

  • Updated dependencies [4f3f564]
    • @​ai-sdk/google@​3.0.70

4.0.123

Patch Changes

  • Updated dependencies [f591416]
  • Updated dependencies [bb377ba]
    • @​ai-sdk/provider-utils@​4.0.27
    • @​ai-sdk/google@​3.0.69
    • @​ai-sdk/anthropic@​3.0.76
    • @​ai-sdk/openai-compatible@​2.0.47

4.0.122

Patch Changes

  • Updated dependencies [e0f8c9e]
    • @​ai-sdk/google@​3.0.68
Commits

Updates `@ai-sdk/openai` from 3.0.61 to 3.0.63
Changelog

Sourced from @​ai-sdk/openai's changelog.

3.0.63

Patch Changes

  • Updated dependencies [f591416]
    • @​ai-sdk/provider-utils@​4.0.27

3.0.62

Patch Changes

  • 65edcca: feat: add allowedTools provider option for OpenAI Responses
Commits

Updates `@ai-sdk/openai-compatible` from 2.0.46 to 2.0.47
Changelog

Sourced from @​ai-sdk/openai-compatible's changelog.

2.0.47

Patch Changes

  • Updated dependencies [f591416]
    • @​ai-sdk/provider-utils@​4.0.27
Commits

Updates `ai` from 6.0.175 to 6.0.177
Changelog

Sourced from ai's changelog.

6.0.177

Patch Changes

  • Updated dependencies [5c73af8]
    • @​ai-sdk/gateway@​3.0.112

6.0.176

Patch Changes

  • f591416: feat(ai): add toolMetadata for tool specific metdata
  • Updated dependencies [f591416]
    • @​ai-sdk/provider-utils@​4.0.27
    • @​ai-sdk/gateway@​3.0.111
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 10 +- packages/cli/package.json | 8 +- packages/core/package.json | 10 +- packages/extension/package.json | 4 +- pnpm-lock.yaml | 191 +++++++++++++++++++------------- 5 files changed, 129 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index 9074799b..fbf26192 100644 --- a/package.json +++ b/package.json @@ -50,13 +50,13 @@ "author": "", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/google": "^3.0.64", - "@ai-sdk/google-vertex": "^4.0.112", - "@ai-sdk/openai": "^3.0.53", - "@ai-sdk/openai-compatible": "^2.0.41", + "@ai-sdk/google": "^3.0.72", + "@ai-sdk/google-vertex": "^4.0.126", + "@ai-sdk/openai": "^3.0.63", + "@ai-sdk/openai-compatible": "^2.0.47", "@ghostery/adblocker-playwright": "^2.14.1", "@openrouter/ai-sdk-provider": "^2.8.0", - "ai": "^6.0.168", + "ai": "^6.0.177", "chalk": "^5.6.2", "commander": "^14.0.3", "cross-fetch": "^4.1.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1d339047..7d81a5ff 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,10 +19,10 @@ "web-ext": "^10.0.0" }, "devDependencies": { - "@ai-sdk/google": "^3.0.64", - "@ai-sdk/google-vertex": "^4.0.112", - "@ai-sdk/openai": "^3.0.53", - "@ai-sdk/openai-compatible": "^2.0.41", + "@ai-sdk/google": "^3.0.72", + "@ai-sdk/google-vertex": "^4.0.126", + "@ai-sdk/openai": "^3.0.63", + "@ai-sdk/openai-compatible": "^2.0.47", "@openrouter/ai-sdk-provider": "^2.8.0", "@types/node": "^25.5.2", "ollama-ai-provider-v2": "^3.5.0", diff --git a/packages/core/package.json b/packages/core/package.json index 531282da..a8e24d05 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,14 +30,14 @@ "check:schemas": "pnpm run generate:schemas && git diff --exit-code schemas/" }, "dependencies": { - "@ai-sdk/google": "^3.0.64", - "@ai-sdk/google-vertex": "^4.0.112", - "@ai-sdk/openai": "^3.0.53", - "@ai-sdk/openai-compatible": "^2.0.41", + "@ai-sdk/google": "^3.0.72", + "@ai-sdk/google-vertex": "^4.0.126", + "@ai-sdk/openai": "^3.0.63", + "@ai-sdk/openai-compatible": "^2.0.47", "@ghostery/adblocker-playwright": "^2.14.1", "@openrouter/ai-sdk-provider": "^2.8.0", "@tabstack/sdk": "^2.3.0", - "ai": "^6.0.168", + "ai": "^6.0.177", "chalk": "^5.6.2", "commander": "^14.0.3", "cross-fetch": "^4.1.0", diff --git a/packages/extension/package.json b/packages/extension/package.json index 9404fc8d..779df9cf 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -21,8 +21,8 @@ "test:e2e:headless": "HEADLESS=true playwright test" }, "dependencies": { - "@ai-sdk/google": "^3.0.64", - "@ai-sdk/openai": "^3.0.53", + "@ai-sdk/google": "^3.0.72", + "@ai-sdk/openai": "^3.0.63", "@openrouter/ai-sdk-provider": "^2.8.0", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0609507..b79c3694 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,26 +12,26 @@ importers: .: dependencies: '@ai-sdk/google': - specifier: ^3.0.64 - version: 3.0.67(zod@4.3.6) + specifier: ^3.0.72 + version: 3.0.72(zod@4.3.6) '@ai-sdk/google-vertex': - specifier: ^4.0.112 - version: 4.0.121(zod@4.3.6) + specifier: ^4.0.126 + version: 4.0.126(zod@4.3.6) '@ai-sdk/openai': - specifier: ^3.0.53 - version: 3.0.61(zod@4.3.6) + specifier: ^3.0.63 + version: 3.0.63(zod@4.3.6) '@ai-sdk/openai-compatible': - specifier: ^2.0.41 - version: 2.0.46(zod@4.3.6) + specifier: ^2.0.47 + version: 2.0.47(zod@4.3.6) '@ghostery/adblocker-playwright': specifier: ^2.14.1 version: 2.15.0(playwright@1.59.1) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 - version: 2.9.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) ai: - specifier: ^6.0.168 - version: 6.0.175(zod@4.3.6) + specifier: ^6.0.177 + version: 6.0.177(zod@4.3.6) chalk: specifier: ^5.6.2 version: 5.6.2 @@ -55,7 +55,7 @@ importers: version: 5.1.11 ollama-ai-provider-v2: specifier: ^3.5.0 - version: 3.5.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) playwright: specifier: 1.59.1 version: 1.59.1 @@ -104,26 +104,26 @@ importers: version: 10.1.0(jiti@2.7.0) devDependencies: '@ai-sdk/google': - specifier: ^3.0.64 - version: 3.0.67(zod@4.3.6) + specifier: ^3.0.72 + version: 3.0.72(zod@4.3.6) '@ai-sdk/google-vertex': - specifier: ^4.0.112 - version: 4.0.121(zod@4.3.6) + specifier: ^4.0.126 + version: 4.0.126(zod@4.3.6) '@ai-sdk/openai': - specifier: ^3.0.53 - version: 3.0.61(zod@4.3.6) + specifier: ^3.0.63 + version: 3.0.63(zod@4.3.6) '@ai-sdk/openai-compatible': - specifier: ^2.0.41 - version: 2.0.46(zod@4.3.6) + specifier: ^2.0.47 + version: 2.0.47(zod@4.3.6) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 - version: 2.9.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) '@types/node': specifier: ^25.5.2 version: 25.6.0 ollama-ai-provider-v2: specifier: ^3.5.0 - version: 3.5.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -137,29 +137,29 @@ importers: packages/core: dependencies: '@ai-sdk/google': - specifier: ^3.0.64 - version: 3.0.67(zod@4.3.6) + specifier: ^3.0.72 + version: 3.0.72(zod@4.3.6) '@ai-sdk/google-vertex': - specifier: ^4.0.112 - version: 4.0.121(zod@4.3.6) + specifier: ^4.0.126 + version: 4.0.126(zod@4.3.6) '@ai-sdk/openai': - specifier: ^3.0.53 - version: 3.0.61(zod@4.3.6) + specifier: ^3.0.63 + version: 3.0.63(zod@4.3.6) '@ai-sdk/openai-compatible': - specifier: ^2.0.41 - version: 2.0.46(zod@4.3.6) + specifier: ^2.0.47 + version: 2.0.47(zod@4.3.6) '@ghostery/adblocker-playwright': specifier: ^2.14.1 version: 2.15.0(playwright@1.59.1) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 - version: 2.9.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) '@tabstack/sdk': specifier: ^2.3.0 version: 2.6.1 ai: - specifier: ^6.0.168 - version: 6.0.175(zod@4.3.6) + specifier: ^6.0.177 + version: 6.0.177(zod@4.3.6) chalk: specifier: ^5.6.2 version: 5.6.2 @@ -183,7 +183,7 @@ importers: version: 5.1.11 ollama-ai-provider-v2: specifier: ^3.5.0 - version: 3.5.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) playwright: specifier: 1.59.1 version: 1.59.1 @@ -231,14 +231,14 @@ importers: packages/extension: dependencies: '@ai-sdk/google': - specifier: ^3.0.64 - version: 3.0.67(zod@4.3.6) + specifier: ^3.0.72 + version: 3.0.72(zod@4.3.6) '@ai-sdk/openai': - specifier: ^3.0.53 - version: 3.0.61(zod@4.3.6) + specifier: ^3.0.63 + version: 3.0.63(zod@4.3.6) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 - version: 2.9.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -277,7 +277,7 @@ importers: version: 4.0.0(react@19.2.5) ollama-ai-provider-v2: specifier: ^3.5.0 - version: 3.5.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6) + version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) pilo-core: specifier: workspace:* version: link:../core @@ -415,38 +415,38 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@ai-sdk/anthropic@3.0.75': - resolution: {integrity: sha512-5AV3CKwaOJFdGXhihVgvRLNrjwRn2Xmy71YygT8DYOA+5zTx93Seg2QSIS8b3tJxzZ7X4H84pEtrE8VZKBCZGA==} + '@ai-sdk/anthropic@3.0.76': + resolution: {integrity: sha512-kOuvT9e6PygFvgYpkr4v9gjvmcMPfJp79jaXjeRl9Gpoj2OXdtc3ero7o1ic+tiSBw5IMubxXFO68BCA/axGJA==} engines: {node: '>=18'} peerDependencies: zod: 4.3.6 - '@ai-sdk/gateway@3.0.110': - resolution: {integrity: sha512-sbv8+1L9/BRKydn8dMNwoMQKupA4iLJ9N+yvxgW6wMQ/94UepDf3FeYWMj/dLdzolAHZ6izRUP4s5WqQkmJ2Zg==} + '@ai-sdk/gateway@3.0.112': + resolution: {integrity: sha512-jiBao9pR4owWyjo0BnuNc7WSQBGOD0thysE4AFgZXaG+zMFbISQXUkJr7ePw/phBvePy7jE5FSA2Lf7lwqUiiQ==} engines: {node: '>=18'} peerDependencies: zod: 4.3.6 - '@ai-sdk/google-vertex@4.0.121': - resolution: {integrity: sha512-MAd6xphN/S2f43psjY2ZrjlHkSckvPnV0nzf98Tgm1hNeSV3ric2wVB4c9UyFzTEKPL0bFye95a1pvYCd6zulw==} + '@ai-sdk/google-vertex@4.0.126': + resolution: {integrity: sha512-zHUrsk8N5gHhOnQccEprySiAc8WxgUkBX6MeT3kDoNzmecqJCLpyKXBQ9cGuBOggo0Bh56s4yrPTN79se/giLg==} engines: {node: '>=18'} peerDependencies: zod: 4.3.6 - '@ai-sdk/google@3.0.67': - resolution: {integrity: sha512-Qeq+SidYtzMrcf0fdw3L0QLmtXK+ErwdBzbxS4+0Q/2UP85Ges8RJJcbAj7SO8e2JbeJoM35BLqkeNy1o3wJvQ==} + '@ai-sdk/google@3.0.72': + resolution: {integrity: sha512-9EVG0AdUhs0hJ2+sqRb4IURZnsBahuU1CQELu+hfItm0wUTzxnVhIbQ4/jJkG/QFPtBUvMefCwDT7HX8T2HVbQ==} engines: {node: '>=18'} peerDependencies: zod: 4.3.6 - '@ai-sdk/openai-compatible@2.0.46': - resolution: {integrity: sha512-23ExGdy3p0Grfz3BAjCbIOc74TjQc5nHu72e0+kx3hshvScp32a4nnQlzzG4VT1bDZxa9yPNNUNyb5nN6vJHcQ==} + '@ai-sdk/openai-compatible@2.0.47': + resolution: {integrity: sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg==} engines: {node: '>=18'} peerDependencies: zod: 4.3.6 - '@ai-sdk/openai@3.0.61': - resolution: {integrity: sha512-L5FdlTo+7IBOdXbnTZqVzowRdLGWnxPn01vHe4leLPp0G4ydoXhySu0tp0ttkoO0ZSzPjHo7iiAkYoT0J91Q8Q==} + '@ai-sdk/openai@3.0.63': + resolution: {integrity: sha512-4yY/m8a57MNNVoJCsXuNblKf6BO4yuAuLKRX4tzSNffBEBSp1FlcWdPE0Z4FkqUeS0AJhYSSqp0GIiA/cIcDNA==} engines: {node: '>=18'} peerDependencies: zod: 4.3.6 @@ -457,6 +457,12 @@ packages: peerDependencies: zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.27': + resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} + engines: {node: '>=18'} + peerDependencies: + zod: 4.3.6 + '@ai-sdk/provider@3.0.10': resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} engines: {node: '>=18'} @@ -490,6 +496,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -498,6 +508,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} @@ -511,6 +525,10 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -2380,8 +2398,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ai@6.0.175: - resolution: {integrity: sha512-6fFFHzbh6FIZnYc31V6osOxq25ABJYCShfG0O6ajHiA4FB/DgnPi1mP8cO5aAU3HNSbQHiMazdlh9bIsp97mVA==} + ai@6.0.177: + resolution: {integrity: sha512-1xQtbeWwNcLyyM86ixZhkKvT+WRXc1lvarIKqPVtsyn8F9NDikwUMBqYu+aQKDgMht50SMXh4qboYuU8MeHZZA==} engines: {node: '>=18'} peerDependencies: zod: 4.3.6 @@ -4808,47 +4826,47 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@ai-sdk/anthropic@3.0.75(zod@4.3.6)': + '@ai-sdk/anthropic@3.0.76(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/gateway@3.0.110(zod@4.3.6)': + '@ai-sdk/gateway@3.0.112(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) '@vercel/oidc': 3.2.0 zod: 4.3.6 - '@ai-sdk/google-vertex@4.0.121(zod@4.3.6)': + '@ai-sdk/google-vertex@4.0.126(zod@4.3.6)': dependencies: - '@ai-sdk/anthropic': 3.0.75(zod@4.3.6) - '@ai-sdk/google': 3.0.67(zod@4.3.6) - '@ai-sdk/openai-compatible': 2.0.46(zod@4.3.6) + '@ai-sdk/anthropic': 3.0.76(zod@4.3.6) + '@ai-sdk/google': 3.0.72(zod@4.3.6) + '@ai-sdk/openai-compatible': 2.0.47(zod@4.3.6) '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) google-auth-library: 10.6.2 zod: 4.3.6 transitivePeerDependencies: - supports-color - '@ai-sdk/google@3.0.67(zod@4.3.6)': + '@ai-sdk/google@3.0.72(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/openai-compatible@2.0.46(zod@4.3.6)': + '@ai-sdk/openai-compatible@2.0.47(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/openai@3.0.61(zod@4.3.6)': + '@ai-sdk/openai@3.0.63(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) zod: 4.3.6 '@ai-sdk/provider-utils@4.0.26(zod@4.3.6)': @@ -4858,6 +4876,13 @@ snapshots: eventsource-parser: 3.0.8 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.27(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 4.3.6 + '@ai-sdk/provider@3.0.10': dependencies: json-schema: 0.4.0 @@ -4897,10 +4922,18 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -4909,6 +4942,8 @@ snapshots: '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -5429,9 +5464,9 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@openrouter/ai-sdk-provider@2.9.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6)': + '@openrouter/ai-sdk-provider@2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6)': dependencies: - ai: 6.0.175(zod@4.3.6) + ai: 6.0.177(zod@4.3.6) zod: 4.3.6 '@opentelemetry/api-logs@0.216.0': @@ -6331,8 +6366,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -6585,11 +6620,11 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.175(zod@4.3.6): + ai@6.0.177(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 3.0.110(zod@4.3.6) + '@ai-sdk/gateway': 3.0.112(zod@4.3.6) '@ai-sdk/provider': 3.0.10 - '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) '@opentelemetry/api': 1.9.0 zod: 4.3.6 @@ -7915,11 +7950,11 @@ snapshots: ohash@2.0.11: {} - ollama-ai-provider-v2@3.5.0(ai@6.0.175(zod@4.3.6))(zod@4.3.6): + ollama-ai-provider-v2@3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6): dependencies: '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.26(zod@4.3.6) - ai: 6.0.175(zod@4.3.6) + ai: 6.0.177(zod@4.3.6) zod: 4.3.6 on-exit-leak-free@2.1.2: {} From 5e7a96f25d301dcbb928893bf85c1d4b57245cb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:25:51 -0700 Subject: [PATCH 09/44] build(deps): bump the react group with 2 updates (#467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the react group with 2 updates: [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom). Updates `react` from 19.2.5 to 19.2.6
Release notes

Sourced from react's releases.

19.2.6 (May 6th, 2026)

React Server Components

Commits

Updates `react-dom` from 19.2.5 to 19.2.6
Release notes

Sourced from react-dom's releases.

19.2.6 (May 6th, 2026)

React Server Components

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/extension/package.json | 4 +- pnpm-lock.yaml | 438 ++++++++++++++++---------------- 2 files changed, 221 insertions(+), 221 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 779df9cf..a8394d6d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -37,8 +37,8 @@ "lucide-react": "^1.8.0", "marked-react": "^4.0.0", "ollama-ai-provider-v2": "^3.5.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", + "react": "^19.2.6", + "react-dom": "^19.2.6", "pilo-core": "workspace:*", "tailwind-merge": "^3.3.0", "tailwindcss": "^4.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b79c3694..bf9e5ca8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,19 +241,19 @@ importers: version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 - version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-label': specifier: ^2.1.7 - version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-scroll-area': specifier: ^1.2.9 - version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-select': specifier: ^2.2.5 - version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-slot': specifier: ^1.2.3 - version: 1.2.4(@types/react@19.2.14)(react@19.2.5) + version: 1.2.4(@types/react@19.2.14)(react@19.2.6) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) @@ -271,10 +271,10 @@ importers: version: 2.1.1 lucide-react: specifier: ^1.8.0 - version: 1.14.0(react@19.2.5) + version: 1.14.0(react@19.2.6) marked-react: specifier: ^4.0.0 - version: 4.0.0(react@19.2.5) + version: 4.0.0(react@19.2.6) ollama-ai-provider-v2: specifier: ^3.5.0 version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) @@ -282,11 +282,11 @@ importers: specifier: workspace:* version: link:../core react: - specifier: ^19.2.5 - version: 19.2.5 + specifier: ^19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) tailwind-merge: specifier: ^3.3.0 version: 3.5.0 @@ -304,7 +304,7 @@ importers: version: 2.8.4 zustand: specifier: ^5.0.12 - version: 5.0.13(@types/react@19.2.14)(react@19.2.5) + version: 5.0.13(@types/react@19.2.14)(react@19.2.6) devDependencies: '@fontsource-variable/geist': specifier: ^5.2.5 @@ -320,7 +320,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) @@ -4007,10 +4007,10 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: - react: ^19.2.5 + react: ^19.2.6 react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -4045,8 +4045,8 @@ packages: '@types/react': optional: true - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} readable-stream@2.3.8: @@ -5224,11 +5224,11 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/dom': 1.7.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) '@floating-ui/utils@0.2.11': {} @@ -5812,324 +5812,324 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) '@radix-ui/rect': 1.1.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.2.5 + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6384,12 +6384,12 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -7811,9 +7811,9 @@ snapshots: lru-cache@11.3.6: {} - lucide-react@1.14.0(react@19.2.5): + lucide-react@1.14.0(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 lz-string@1.5.0: {} @@ -7835,11 +7835,11 @@ snapshots: many-keys-map@3.0.3: {} - marked-react@4.0.0(react@19.2.5): + marked-react@4.0.0(react@19.2.6): dependencies: html-entities: 2.6.0 marked: 17.0.6 - react: 19.2.5 + react: 19.2.6 marked@17.0.6: {} @@ -8228,41 +8228,41 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-dom@19.2.5(react@19.2.5): + react-dom@19.2.6(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 scheduler: 0.27.0 react-is@17.0.2: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): dependencies: - react: 19.2.5 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.6): dependencies: - react: 19.2.5 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): dependencies: get-nonce: 1.0.1 - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - react@19.2.5: {} + react@19.2.6: {} readable-stream@2.3.8: dependencies: @@ -8728,17 +8728,17 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): dependencies: detect-node-es: 1.1.0 - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.14 @@ -9130,7 +9130,7 @@ snapshots: zod@4.3.6: {} - zustand@5.0.13(@types/react@19.2.14)(react@19.2.5): + zustand@5.0.13(@types/react@19.2.14)(react@19.2.6): optionalDependencies: '@types/react': 19.2.14 - react: 19.2.5 + react: 19.2.6 From f02fc90473fad3a0e9534371e4aff1babed18621 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:26:06 -0700 Subject: [PATCH 10/44] build(deps-dev): bump the devdependencies group with 3 updates (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the devdependencies group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) and [wxt](https://github.com/wxt-dev/wxt). Updates `@types/node` from 25.6.0 to 25.7.0
Commits

Updates `vitest` from 4.1.5 to 4.1.6
Release notes

Sourced from vitest's releases.

v4.1.6

   🐞 Bug Fixes

   🏎 Performance

    View changes on GitHub
Commits
  • a8fd24c chore: release v4.1.6
  • 18af98c fix(browser): simplify orchestrator otel carrier (#10285)
  • 3188260 feat(browser): provide project reference in ToMatchScreenshotResolvePath (#...
  • See full diff in compare view

Updates `wxt` from 0.20.25 to 0.20.26
Release notes

Sourced from wxt's releases.

wxt v0.20.26

compare changes

🚀 Enhancements

  • Add default_state option to popup actions (#2010)
  • Add content script noScriptStartedPostMessage option (#2265)

🩹 Fixes

  • Use manifestVersion from CLI during manifest generation (#2306)
  • Modify command to support variadic positional args (#2317)
  • Bump publish-browser-extension to v4.0.5, to resolve Chrome Web Store submission issue (#2331)
  • Avoid errors when files are removed during build (#2343)
  • manifest: Exclude open_in_tab from options_ui for Safari (#2311)

📖 Documentation

  • wxt-modules: Add logging examples/best practices (57e3748d)
  • Correct entrypoints icon example code (#2302)
  • Add types for components (#2273)
  • Fix JSDoc example formatting (2a8ec0d7)
  • Remove unused code (#2275)
  • Fix horizontal scrollbar displaying on landing page (#2329)
  • Add Mimik extension to the list of extensions (#2319)
  • Added modrinth extras and pi-hole in one to showcase (#2337)
  • Added "QIE Wallet" to showcase (#2341)
  • Update Safari publishing instructions to match Apple docs (#2314)
  • Add "Redmine Time Tracking" to extension showcase (#2312)

🏡 Chore

  • More JSDoc fixes (9b59f38c)
  • Move createFileReloader into it's own file (#2307)
  • Remove ts-expect-error that are no longer needed (#2344)
  • deps-dev: Bump typescript from 5.9.3 to 6.0.3 (#2325)
  • deps-dev: Bump oxlint from 1.59.0 to 1.63.0 (#2356)
  • Use catalog: for dev dependencies (#2357)

❤️ Contributors

... (truncated)

Commits
  • 6f14aa1 chore(release): wxt v0.20.26
  • f771e6a feat: Add content script noScriptStartedPostMessage option (#2265)
  • a0a2394 chore(deps): Upgrade vitest and related deps (#2358)
  • 7b6f1dc fix(manifest): exclude open_in_tab from options_ui for Safari (#2311)
  • 16f9e17 chore: Update codeowners
  • 40e28b7 docs: Add "Redmine Time Tracking" to extension showcase (#2312)
  • 75f0b84 docs: update Safari publishing instructions to match Apple docs (#2314)
  • 8793385 chore: Use catalog: for dev dependencies (#2357)
  • 4f86143 fix: avoid errors when files are removed during build (#2343)
  • 498f74b chore(deps-dev): bump oxlint from 1.59.0 to 1.63.0 (#2356)
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/cli/package.json | 4 +- packages/core/package.json | 4 +- packages/extension/package.json | 4 +- packages/server/package.json | 4 +- pnpm-lock.yaml | 669 ++++++++++++++++++-------------- 5 files changed, 382 insertions(+), 303 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 7d81a5ff..5057c209 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,10 +24,10 @@ "@ai-sdk/openai": "^3.0.63", "@ai-sdk/openai-compatible": "^2.0.47", "@openrouter/ai-sdk-provider": "^2.8.0", - "@types/node": "^25.5.2", + "@types/node": "^25.7.0", "ollama-ai-provider-v2": "^3.5.0", "tsx": "^4.21.0", "typescript": "^6.0.3", - "vitest": "^4.1.5" + "vitest": "^4.1.6" } } diff --git a/packages/core/package.json b/packages/core/package.json index a8e24d05..27504ab9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,7 +61,7 @@ "devDependencies": { "@opentelemetry/api": "^1.9.1", "@types/jsdom": "^28.0.1", - "@types/node": "^25.5.2", + "@types/node": "^25.7.0", "@types/turndown": "^5.0.6", "@vitest/coverage-v8": "^4.1.4", "esbuild": "^0.28.0", @@ -69,6 +69,6 @@ "ts-json-schema-generator": "2.9.0", "tsx": "^4.21.0", "typescript": "^6.0.3", - "vitest": "^4.1.5" + "vitest": "^4.1.6" } } diff --git a/packages/extension/package.json b/packages/extension/package.json index a8394d6d..46dcb160 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -61,8 +61,8 @@ "happy-dom": "^20.8.9", "tw-animate-css": "^1.3.4", "typescript": "^6.0.3", - "vitest": "^4.1.5", - "wxt": "0.20.25" + "vitest": "^4.1.6", + "wxt": "0.20.26" }, "packageManager": "pnpm@9.0.0" } diff --git a/packages/server/package.json b/packages/server/package.json index e0e6b1ff..68629b6b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -23,10 +23,10 @@ "author": "", "license": "Apache-2.0", "devDependencies": { - "@types/node": "^25.5.2", + "@types/node": "^25.7.0", "tsx": "^4.21.0", "typescript": "^6.0.3", - "vitest": "^4.1.5" + "vitest": "^4.1.6" }, "dependencies": { "@hono/node-server": "^2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf9e5ca8..be3ac2e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ importers: version: 3.8.3 tsup: specifier: ^8.3.5 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -89,7 +89,7 @@ importers: dependencies: '@inquirer/prompts': specifier: ^8.4.1 - version: 8.4.2(@types/node@25.6.0) + version: 8.4.2(@types/node@25.9.1) chalk: specifier: ^5.6.2 version: 5.6.2 @@ -119,8 +119,8 @@ importers: specifier: ^2.8.0 version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) '@types/node': - specifier: ^25.5.2 - version: 25.6.0 + specifier: ^25.7.0 + version: 25.9.1 ollama-ai-provider-v2: specifier: ^3.5.0 version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) @@ -131,8 +131,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/core: dependencies: @@ -201,14 +201,14 @@ importers: specifier: ^28.0.1 version: 28.0.1 '@types/node': - specifier: ^25.5.2 - version: 25.6.0 + specifier: ^25.7.0 + version: 25.9.1 '@types/turndown': specifier: ^5.0.6 version: 5.0.6 '@vitest/coverage-v8': specifier: ^4.1.4 - version: 4.1.5(vitest@4.1.5) + version: 4.1.5(vitest@4.1.6) esbuild: specifier: ^0.28.0 version: 0.28.0 @@ -225,8 +225,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/extension: dependencies: @@ -256,13 +256,13 @@ importers: version: 1.2.4(@types/react@19.2.14)(react@19.2.6) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.2.4(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) '@types/webextension-polyfill': specifier: ^0.12.5 version: 0.12.5 '@wxt-dev/webextension-polyfill': specifier: ^1.0.0 - version: 1.0.0(webextension-polyfill@0.12.0)(wxt@0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4)) + version: 1.0.0(webextension-polyfill@0.12.0)(wxt@0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -335,7 +335,7 @@ importers: version: 5.0.6 '@wxt-dev/module-react': specifier: ^1.2.2 - version: 1.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(wxt@0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4)) + version: 1.2.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(wxt@0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4)) happy-dom: specifier: ^20.8.9 version: 20.9.0 @@ -346,11 +346,11 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) wxt: - specifier: 0.20.25 - version: 0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4) + specifier: 0.20.26 + version: 0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4) packages/server: dependencies: @@ -395,8 +395,8 @@ importers: version: link:../core devDependencies: '@types/node': - specifier: ^25.5.2 - version: 25.6.0 + specifier: ^25.7.0 + version: 25.9.1 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -404,8 +404,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + specifier: ^4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -504,6 +504,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -517,6 +521,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.2': resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} engines: {node: '>=6.9.0'} @@ -533,6 +542,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1435,8 +1448,8 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} - '@oxc-project/types@0.127.0': - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1858,101 +1871,101 @@ packages: '@remusao/trie@2.1.0': resolution: {integrity: sha512-Er3Q8q0/2OcCJPQYJOPLmCuqO0wu7cav3SPtpjlxSbjFi1x+A1pZkkLD6c9q2rGEkGW/tkrRzfrhNMt8VQjzXg==} - '@rolldown/binding-android-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.17': - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} - '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.60.3': resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} cpu: [arm] @@ -2230,6 +2243,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/filesystem@0.0.36': resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} @@ -2248,8 +2264,8 @@ packages: '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -2265,6 +2281,9 @@ packages: '@types/turndown@5.0.6': resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/webextension-polyfill@0.10.7': + resolution: {integrity: sha512-10ql7A0qzBmFB+F+qAke/nP1PIonS0TXZAOMVOxEUsm+lGSW6uwVcISFNa0I4Oyj0884TZVWGGMIWeXOVSNFHw==} + '@types/webextension-polyfill@0.12.5': resolution: {integrity: sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==} @@ -2300,11 +2319,11 @@ packages: '@vitest/browser': optional: true - '@vitest/expect@4.1.5': - resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} - '@vitest/mocker@4.1.5': - resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2317,20 +2336,26 @@ packages: '@vitest/pretty-format@4.1.5': resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - '@vitest/runner@4.1.5': - resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} - '@vitest/snapshot@4.1.5': - resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} - '@vitest/spy@4.1.5': - resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} - '@webext-core/fake-browser@1.3.4': - resolution: {integrity: sha512-nZcVWr3JpwpS5E6hKpbAwAMBM/AXZShnfW0F76udW8oLd6Kv0nbW6vFS07md4Na/0ntQonk3hFnlQYGYBAlTrA==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + '@webext-core/fake-browser@1.5.0': + resolution: {integrity: sha512-dq5LWtr3XjB9B9LSgEZo4JUEmwBGAxa5CHbn7CvCC2jf4SOG0iBD20QSD3dpvcHBXTIHCVbB4bsDVFzlXMnYww==} '@webext-core/isolated-element@1.1.5': resolution: {integrity: sha512-4m6oP8Vzm/68YO1QmkUOZqqUcmyBtA53tji2g00/nYXE3E3IceYgeub7eIqvXDV2Z7xU6cm6qO1IMt4XFVwtvQ==} @@ -2338,8 +2363,8 @@ packages: '@webext-core/match-patterns@1.0.3': resolution: {integrity: sha512-NY39ACqCxdKBmHgw361M9pfJma8e4AZo20w9AY+5ZjIj1W2dvXC8J31G5fjfOGbulW9w4WKpT8fPooi0mLkn9A==} - '@wxt-dev/browser@0.1.40': - resolution: {integrity: sha512-h2/v/Hpkj5sz//h84ProqBaAcTsDFRKp9b/JVHOK/r7LT0XLE+ZDs5YN1BnFLUEHdM7G3fUjTyBG84cayXQshQ==} + '@wxt-dev/browser@0.1.42': + resolution: {integrity: sha512-BSb0H09i4+0WlqFnN7LnZW0M6uCPH9KndciGx7mwOFKRVjNCorqwPk3hiVB58yQ+cyJNpDvYWL6IoPBxvP8qEA==} '@wxt-dev/module-react@1.2.2': resolution: {integrity: sha512-+lRLi1r9dAXpLySWSIWHLJ1h/nFzR20iQnx3RNrKyA6oJg4+ClOluVXozHjfPg9Okfy/umtffiOopGayASrg6w==} @@ -3113,6 +3138,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -3575,8 +3604,8 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} locate-path@6.0.0: @@ -3636,6 +3665,9 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -3935,8 +3967,8 @@ packages: yaml: optional: true - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -4106,8 +4138,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4152,6 +4184,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + set-value@4.1.0: resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==} engines: {node: '>=11.0'} @@ -4334,8 +4371,8 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.1.2: - resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + tinyexec@1.2.2: + resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==} engines: {node: '>=18'} tinyglobby@0.2.16: @@ -4451,8 +4488,8 @@ packages: uhyphen@0.2.0: resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici-types@7.25.0: resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} @@ -4461,14 +4498,17 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} - unimport@6.2.0: - resolution: {integrity: sha512-4NcqaphAHQff4eBWQ3pjVOCYNLlmVGGMoLDmboobh8+OQe9yP7UyeoMP043M1bG0YNc3CqtukD2VuINxOqm4rQ==} + unimport@6.3.0: + resolution: {integrity: sha512-M+Dxk5W9WRd+8j56W9tp8lGW/dmMc7g5zj7BWQnEjKQhryBstqsi1V0izb0zHwSkEN8cSYV7K75/bykairV2tA==} engines: {node: '>=18.12.0'} peerDependencies: oxc-parser: '*' + rolldown: ^1.0.0 peerDependenciesMeta: oxc-parser: optional: true + rolldown: + optional: true universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} @@ -4526,13 +4566,13 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - vite@8.0.10: - resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 + '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -4569,20 +4609,20 @@ packages: yaml: optional: true - vitest@4.1.5: - resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.5 - '@vitest/browser-preview': 4.1.5 - '@vitest/browser-webdriverio': 4.1.5 - '@vitest/coverage-istanbul': 4.1.5 - '@vitest/coverage-v8': 4.1.5 - '@vitest/ui': 4.1.5 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4734,8 +4774,8 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} - wxt@0.20.25: - resolution: {integrity: sha512-ca+8Yt0Auzn9tX0ZW2Kzocb9yM8F/RoOjcYQ0fHkwcSc7/IUkqV2+1JUNn1SMSNAS4Gr3YQHAn/pi3q+jIGRqw==} + wxt@0.20.26: + resolution: {integrity: sha512-PMGz7sAlONJgwBkOriInXOoEU6/jlGKrhSFvZfiBPHZocyYPfnw1lod9rGDra957H83WO+TnGjYwJiGYciSIqA==} engines: {bun: '>=1.2.0', node: '>=20.12.0'} hasBin: true peerDependencies: @@ -4930,6 +4970,8 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -4938,6 +4980,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/runtime@7.28.2': {} '@babel/runtime@7.29.2': {} @@ -4949,6 +4995,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} '@bramus/specificity@2.4.2': @@ -5315,122 +5366,122 @@ snapshots: '@inquirer/ansi@2.0.5': {} - '@inquirer/checkbox@5.1.4(@types/node@25.6.0)': + '@inquirer/checkbox@5.1.4(@types/node@25.9.1)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/confirm@6.0.12(@types/node@25.6.0)': + '@inquirer/confirm@6.0.12(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/core@11.1.9(@types/node@25.6.0)': + '@inquirer/core@11.1.9(@types/node@25.9.1)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.9.1) cli-width: 4.1.0 fast-wrap-ansi: 0.2.0 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/editor@5.1.1(@types/node@25.6.0)': + '@inquirer/editor@5.1.1(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/external-editor': 3.0.0(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) + '@inquirer/external-editor': 3.0.0(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/expand@5.0.13(@types/node@25.6.0)': + '@inquirer/expand@5.0.13(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/external-editor@3.0.0(@types/node@25.6.0)': + '@inquirer/external-editor@3.0.0(@types/node@25.9.1)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@inquirer/figures@2.0.5': {} - '@inquirer/input@5.0.12(@types/node@25.6.0)': + '@inquirer/input@5.0.12(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/number@4.0.12(@types/node@25.6.0)': + '@inquirer/number@4.0.12(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/password@5.0.12(@types/node@25.6.0)': + '@inquirer/password@5.0.12(@types/node@25.9.1)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 - - '@inquirer/prompts@8.4.2(@types/node@25.6.0)': - dependencies: - '@inquirer/checkbox': 5.1.4(@types/node@25.6.0) - '@inquirer/confirm': 6.0.12(@types/node@25.6.0) - '@inquirer/editor': 5.1.1(@types/node@25.6.0) - '@inquirer/expand': 5.0.13(@types/node@25.6.0) - '@inquirer/input': 5.0.12(@types/node@25.6.0) - '@inquirer/number': 4.0.12(@types/node@25.6.0) - '@inquirer/password': 5.0.12(@types/node@25.6.0) - '@inquirer/rawlist': 5.2.8(@types/node@25.6.0) - '@inquirer/search': 4.1.8(@types/node@25.6.0) - '@inquirer/select': 5.1.4(@types/node@25.6.0) + '@types/node': 25.9.1 + + '@inquirer/prompts@8.4.2(@types/node@25.9.1)': + dependencies: + '@inquirer/checkbox': 5.1.4(@types/node@25.9.1) + '@inquirer/confirm': 6.0.12(@types/node@25.9.1) + '@inquirer/editor': 5.1.1(@types/node@25.9.1) + '@inquirer/expand': 5.0.13(@types/node@25.9.1) + '@inquirer/input': 5.0.12(@types/node@25.9.1) + '@inquirer/number': 4.0.12(@types/node@25.9.1) + '@inquirer/password': 5.0.12(@types/node@25.9.1) + '@inquirer/rawlist': 5.2.8(@types/node@25.9.1) + '@inquirer/search': 4.1.8(@types/node@25.9.1) + '@inquirer/select': 5.1.4(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/rawlist@5.2.8(@types/node@25.6.0)': + '@inquirer/rawlist@5.2.8(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/search@4.1.8(@types/node@25.6.0)': + '@inquirer/search@4.1.8(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/select@5.1.4(@types/node@25.6.0)': + '@inquirer/select@5.1.4(@types/node@25.9.1)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.9(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.9.1) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.9.1) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 - '@inquirer/type@4.0.5(@types/node@25.6.0)': + '@inquirer/type@4.0.5(@types/node@25.9.1)': optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -5766,7 +5817,7 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} - '@oxc-project/types@0.127.0': {} + '@oxc-project/types@0.132.0': {} '@pinojs/redact@0.4.0': {} @@ -6153,59 +6204,59 @@ snapshots: '@remusao/trie@2.1.0': {} - '@rolldown/binding-android-arm64@1.0.0-rc.17': + '@rolldown/binding-android-arm64@1.0.2': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + '@rolldown/binding-darwin-arm64@1.0.2': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.17': + '@rolldown/binding-darwin-x64@1.0.2': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + '@rolldown/binding-freebsd-x64@1.0.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-musl@1.0.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-ppc64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-s390x-gnu@1.0.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-x64-gnu@1.0.2': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-x64-musl@1.0.2': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + '@rolldown/binding-openharmony-arm64@1.0.2': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + '@rolldown/binding-wasm32-wasi@1.0.2': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-arm64-msvc@1.0.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-x64-msvc@1.0.2': optional: true - '@rolldown/pluginutils@1.0.0-rc.17': {} - '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.60.3': optional: true @@ -6357,12 +6408,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.2.4(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) '@testing-library/dom@10.4.1': dependencies: @@ -6414,6 +6465,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/filesystem@0.0.36': dependencies: '@types/filewriter': 0.0.33 @@ -6424,7 +6477,7 @@ snapshots: '@types/jsdom@28.0.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 undici-types: 7.25.0 @@ -6433,9 +6486,9 @@ snapshots: '@types/minimatch@3.0.5': {} - '@types/node@25.6.0': + '@types/node@25.9.1': dependencies: - undici-types: 7.19.2 + undici-types: 7.24.6 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -6449,22 +6502,24 @@ snapshots: '@types/turndown@5.0.6': {} + '@types/webextension-polyfill@0.10.7': {} + '@types/webextension-polyfill@0.12.5': {} '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + '@vitejs/plugin-react@6.0.1(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) - '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + '@vitest/coverage-v8@4.1.5(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.5 @@ -6476,50 +6531,54 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) - '@vitest/expect@4.1.5': + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.6(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: - '@vitest/spy': 4.1.5 + '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.6(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.5 + '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.5': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.5': + '@vitest/pretty-format@4.1.6': dependencies: - '@vitest/utils': 4.1.5 + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 pathe: 2.0.3 - '@vitest/snapshot@4.1.5': + '@vitest/snapshot@4.1.6': dependencies: - '@vitest/pretty-format': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.5': {} + '@vitest/spy@4.1.6': {} '@vitest/utils@4.1.5': dependencies: @@ -6527,8 +6586,15 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@webext-core/fake-browser@1.3.4': + '@vitest/utils@4.1.6': dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@webext-core/fake-browser@1.5.0': + dependencies: + '@types/webextension-polyfill': 0.10.7 lodash.merge: 4.6.2 '@webext-core/isolated-element@1.1.5': @@ -6537,30 +6603,30 @@ snapshots: '@webext-core/match-patterns@1.0.3': {} - '@wxt-dev/browser@0.1.40': + '@wxt-dev/browser@0.1.42': dependencies: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-react@1.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(wxt@0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4))': + '@wxt-dev/module-react@1.2.2(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(wxt@0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4))': dependencies: - '@vitejs/plugin-react': 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) - wxt: 0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4) + '@vitejs/plugin-react': 6.0.1(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + wxt: 0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - '@rolldown/plugin-babel' - babel-plugin-react-compiler '@wxt-dev/storage@1.2.8': dependencies: - '@wxt-dev/browser': 0.1.40 + '@wxt-dev/browser': 0.1.42 async-mutex: 0.5.0 dequal: 2.0.3 - '@wxt-dev/webextension-polyfill@1.0.0(webextension-polyfill@0.12.0)(wxt@0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4))': + '@wxt-dev/webextension-polyfill@1.0.0(webextension-polyfill@0.12.0)(wxt@0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4))': dependencies: webextension-polyfill: 0.12.0 - wxt: 0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4) + wxt: 0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4) acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: @@ -6752,7 +6818,7 @@ snapshots: esbuild: 0.27.7 load-tsconfig: 0.2.5 - c12@3.3.4(magicast@0.5.2): + c12@3.3.4(magicast@0.5.3): dependencies: chokidar: 5.0.0 confbox: 0.2.4 @@ -6767,7 +6833,7 @@ snapshots: pkg-types: 2.3.1 rc9: 3.0.1 optionalDependencies: - magicast: 0.5.2 + magicast: 0.5.3 cac@6.7.14: {} @@ -6821,7 +6887,7 @@ snapshots: chrome-launcher@1.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 2.0.2 @@ -7222,7 +7288,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -7350,6 +7416,8 @@ snapshots: get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} + get-nonce@1.0.1: {} get-port-please@3.2.0: {} @@ -7401,7 +7469,7 @@ snapshots: happy-dom@20.9.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -7501,7 +7569,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-glob@4.0.3: dependencies: @@ -7646,7 +7714,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.4 + semver: 7.8.1 jszip@3.10.1: dependencies: @@ -7771,7 +7839,7 @@ snapshots: load-tsconfig@0.2.5: {} - local-pkg@1.1.2: + local-pkg@1.2.1: dependencies: mlly: 1.8.2 pkg-types: 2.3.1 @@ -7827,6 +7895,12 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -7936,7 +8010,7 @@ snapshots: dependencies: citty: 0.2.2 pathe: 2.0.3 - tinyexec: 1.1.2 + tinyexec: 1.2.2 object-assign@4.1.1: {} @@ -8012,7 +8086,7 @@ snapshots: parse-json@7.1.1: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 3.0.2 lines-and-columns: 2.0.4 @@ -8122,16 +8196,16 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.21.0)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.14 + postcss: 8.5.15 tsx: 4.21.0 yaml: 2.9.0 - postcss@8.5.14: + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -8176,7 +8250,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.6.0 + '@types/node': 25.9.1 long: 5.3.2 protobufjs@8.0.1: @@ -8191,7 +8265,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.6.0 + '@types/node': 25.9.1 long: 5.3.2 publish-browser-extension@4.0.5: @@ -8317,26 +8391,26 @@ snapshots: rfdc@1.4.1: {} - rolldown@1.0.0-rc.17: + rolldown@1.0.2: dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 rollup@4.60.3: dependencies: @@ -8391,6 +8465,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.1: {} + set-value@4.1.0: dependencies: is-plain-object: 2.0.4 @@ -8468,7 +8544,7 @@ snapshots: string-width@8.2.1: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 string_decoder@1.1.1: @@ -8560,7 +8636,7 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.1.2: {} + tinyexec@1.2.2: {} tinyglobby@0.2.16: dependencies: @@ -8614,7 +8690,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -8625,7 +8701,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.21.0)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.60.3 source-map: 0.7.6 @@ -8634,7 +8710,7 @@ snapshots: tinyglobby: 0.2.16 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.14 + postcss: 8.5.15 typescript: 6.0.3 transitivePeerDependencies: - jiti @@ -8673,18 +8749,18 @@ snapshots: uhyphen@0.2.0: {} - undici-types@7.19.2: {} + undici-types@7.24.6: {} undici-types@7.25.0: {} undici@7.25.0: {} - unimport@6.2.0: + unimport@6.3.0(rolldown@1.0.2): dependencies: acorn: 8.16.0 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 - local-pkg: 1.1.2 + local-pkg: 1.2.1 magic-string: 0.30.21 mlly: 1.8.2 pathe: 2.0.3 @@ -8695,6 +8771,8 @@ snapshots: tinyglobby: 0.2.16 unplugin: 3.0.0 unplugin-utils: 0.3.1 + optionalDependencies: + rolldown: 1.0.2 universalify@2.0.1: {} @@ -8747,13 +8825,13 @@ snapshots: uuid@8.3.2: {} - vite-node@6.0.0(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): + vite-node@6.0.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): dependencies: cac: 7.0.0 es-module-lexer: 2.1.0 obug: 2.1.1 pathe: 2.0.3 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -8768,45 +8846,45 @@ snapshots: - tsx - yaml - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): + vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.14 - rolldown: 1.0.0-rc.17 + postcss: 8.5.15 + rolldown: 1.0.2 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.21.0 yaml: 2.8.4 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): + vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.14 - rolldown: 1.0.0-rc.17 + postcss: 8.5.15 + rolldown: 1.0.2 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.1 esbuild: 0.28.0 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.21.0 yaml: 2.9.0 - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -8815,29 +8893,29 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.2 + tinyexec: 1.2.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/node': 25.6.0 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + '@types/node': 25.9.1 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.6) happy-dom: 20.9.0 jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -8846,15 +8924,15 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.2 + tinyexec: 1.2.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/node': 25.6.0 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + '@types/node': 25.9.1 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.6) happy-dom: 20.9.0 jsdom: 29.1.1 transitivePeerDependencies: @@ -9022,17 +9100,17 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 - wxt@0.20.25(@types/node@25.6.0)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4): + wxt@0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4): dependencies: '@1natsu/wait-element': 4.2.0 '@aklinker1/rollup-plugin-visualizer': 5.12.0(rollup@4.60.3) - '@webext-core/fake-browser': 1.3.4 + '@webext-core/fake-browser': 1.5.0 '@webext-core/isolated-element': 1.1.5 '@webext-core/match-patterns': 1.0.3 - '@wxt-dev/browser': 0.1.40 + '@wxt-dev/browser': 0.1.42 '@wxt-dev/storage': 1.2.8 async-mutex: 0.5.0 - c12: 3.3.4(magicast@0.5.2) + c12: 3.3.4(magicast@0.5.3) cac: 7.0.0 chokidar: 5.0.0 ci-info: 4.4.0 @@ -9049,7 +9127,7 @@ snapshots: json5: 2.2.3 jszip: 3.10.1 linkedom: 0.18.12 - magicast: 0.5.2 + magicast: 0.5.3 nano-spawn: 2.1.0 nanospinner: 1.2.2 normalize-path: 3.0.0 @@ -9062,9 +9140,9 @@ snapshots: publish-browser-extension: 4.0.5 scule: 1.3.0 tinyglobby: 0.2.16 - unimport: 6.2.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) - vite-node: 6.0.0(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + unimport: 6.3.0(rolldown@1.0.2) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + vite-node: 6.0.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) web-ext-run: 0.2.4 optionalDependencies: eslint: 9.39.4(jiti@2.7.0) @@ -9075,6 +9153,7 @@ snapshots: - jiti - less - oxc-parser + - rolldown - rollup - sass - sass-embedded From b04ed1c2efe04b5b5b04679b3439cad22cb97b75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:26:28 -0700 Subject: [PATCH 11/44] build(deps): bump tailwindcss from 4.2.4 to 4.3.0 (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.4 to 4.3.0.
Release notes

Sourced from tailwindcss's releases.

v4.3.0

Added

  • Add @container-size utility (#18901)
  • Add scrollbar-{auto,thin,none} utilities for scrollbar-width, and scrollbar-thumb-* / scrollbar-track-* color utilities for scrollbar-color (#19981, #20019)
  • Add scrollbar-gutter-* utilities (#20018)
  • Add zoom-* utilities (#20020)
  • Add tab-* utilities (#20022)
  • Allow using @variant with stacked variants (e.g. @variant hover:focus { … }) (#19996)
  • Allow using @variant with compound variants (e.g. @variant hover, focus { … }) (#19996)
  • Support --default(…) in --value(…) and --modifier(…) for functional @utility definitions (#19989)

Fixed

  • Ensure @plugin resolves package JavaScript entries instead of browser CSS entries when using @tailwindcss/vite (#19949)
  • Fix relative @import and @plugin paths resolving from the wrong directory when using @tailwindcss/vite (#19965)
  • Ensure CSS files containing @variant are processed by @tailwindcss/vite (#19966)
  • Resolve imports relative to base when result.opts.from is not provided when using @tailwindcss/postcss (#19980)
  • Canonicalization: preserve significant _ whitespace in arbitrary values (#19986)
  • Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability (e.g. w-[calc(100%---spacing(60))]w-[calc(100%-(--spacing(60)))]) (#19986)
  • Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. -mt-[20in]mt-[-20in], not mt-[-1920px]) (#19988)
  • Canonicalization: migrate arbitrary :has() variants from [&:has(…)] to has-[…] (#19991)
  • Upgrade: don’t migrate inline style attributes (e.g. style="flex-grow: 1"style="flex-grow: 1", not style="grow: 1") (#19918)
  • Allow multiple @utility definitions with the same name but different value types (#19777)
  • Export missing PluginWithConfig type from tailwindcss/plugin to fix errors when inferring plugin config types (#19707)
  • Ensure start and end legacy utilities without values do not generate CSS (#20003)
  • Ensure --value(…) is required in functional @utility definitions (#20005)
  • Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. -left-[(var(--a)+var(--b))]) (#20011)
Changelog

Sourced from tailwindcss's changelog.

[4.3.0] - 2026-05-08

Added

  • Add @container-size utility (#18901)
  • Add scrollbar-{auto,thin,none} utilities for scrollbar-width, and scrollbar-thumb-* / scrollbar-track-* color utilities for scrollbar-color (#19981, #20019)
  • Add scrollbar-gutter-* utilities (#20018)
  • Add zoom-* utilities (#20020)
  • Add tab-* utilities (#20022)
  • Allow using @variant with stacked variants (e.g. @variant hover:focus { … }) (#19996)
  • Allow using @variant with compound variants (e.g. @variant hover, focus { … }) (#19996)
  • Support --default(…) in --value(…) and --modifier(…) for functional @utility definitions (#19989)

Fixed

  • Ensure @plugin resolves package JavaScript entries instead of browser CSS entries when using @tailwindcss/vite (#19949)
  • Fix relative @import and @plugin paths resolving from the wrong directory when using @tailwindcss/vite (#19965)
  • Ensure CSS files containing @variant are processed by @tailwindcss/vite (#19966)
  • Resolve imports relative to base when result.opts.from is not provided when using @tailwindcss/postcss (#19980)
  • Canonicalization: preserve significant _ whitespace in arbitrary values (#19986)
  • Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability (e.g. w-[calc(100%---spacing(60))]w-[calc(100%-(--spacing(60)))]) (#19986)
  • Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. -mt-[20in]mt-[-20in], not mt-[-1920px]) (#19988)
  • Canonicalization: migrate arbitrary :has() variants from [&:has(…)] to has-[…] (#19991)
  • Upgrade: don’t migrate inline style attributes (e.g. style="flex-grow: 1"style="flex-grow: 1", not style="grow: 1") (#19918)
  • Allow multiple @utility definitions with the same name but different value types (#19777)
  • Export missing PluginWithConfig type from tailwindcss/plugin to fix errors when inferring plugin config types (#19707)
  • Ensure start and end legacy utilities without values do not generate CSS (#20003)
  • Ensure --value(…) is required in functional @utility definitions (#20005)
  • Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. -left-[(var(--a)+var(--b))]) (#20011)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tailwindcss&package-manager=npm_and_yarn&previous-version=4.2.4&new-version=4.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/extension/package.json | 2 +- pnpm-lock.yaml | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 46dcb160..3f06bd30 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -41,7 +41,7 @@ "react-dom": "^19.2.6", "pilo-core": "workspace:*", "tailwind-merge": "^3.3.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.3.0", "turndown": "^7.2.2", "webextension-polyfill": "^0.12.0", "yaml": "^2.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be3ac2e1..fb3ddd85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,8 +291,8 @@ importers: specifier: ^3.3.0 version: 3.5.0 tailwindcss: - specifier: ^4.2.2 - version: 4.2.4 + specifier: ^4.3.0 + version: 4.3.0 turndown: specifier: ^7.2.2 version: 7.2.4 @@ -4344,6 +4344,9 @@ packages: tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -8612,6 +8615,8 @@ snapshots: tailwindcss@4.2.4: {} + tailwindcss@4.3.0: {} + tapable@2.3.3: {} thenify-all@1.6.0: From 69f8f25ea303a341c09e32f712caadfda8db6f20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:26:50 -0700 Subject: [PATCH 12/44] build(deps): bump @hono/node-server from 2.0.1 to 2.0.2 (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@hono/node-server](https://github.com/honojs/node-server) from 2.0.1 to 2.0.2.
Release notes

Sourced from @​hono/node-server's releases.

v2.0.2

What's Changed

Full Changelog: https://github.com/honojs/node-server/compare/v2.0.1...v2.0.2

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@hono/node-server&package-manager=npm_and_yarn&previous-version=2.0.1&new-version=2.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index 68629b6b..0928cb3b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -29,7 +29,7 @@ "vitest": "^4.1.6" }, "dependencies": { - "@hono/node-server": "^2.0.1", + "@hono/node-server": "^2.0.2", "@hono/node-ws": "^1.3.0", "@hono/sentry": "^1.2.2", "@opentelemetry/api": "^1.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb3ddd85..24ef3b67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,11 +355,11 @@ importers: packages/server: dependencies: '@hono/node-server': - specifier: ^2.0.1 - version: 2.0.1(hono@4.12.18) + specifier: ^2.0.2 + version: 2.0.2(hono@4.12.18) '@hono/node-ws': specifier: ^1.3.0 - version: 1.3.1(@hono/node-server@2.0.1(hono@4.12.18))(hono@4.12.18) + version: 1.3.1(@hono/node-server@2.0.2(hono@4.12.18))(hono@4.12.18) '@hono/sentry': specifier: ^1.2.2 version: 1.2.2(hono@4.12.18) @@ -1026,8 +1026,8 @@ packages: engines: {node: '>=6'} hasBin: true - '@hono/node-server@2.0.1': - resolution: {integrity: sha512-jI9yMDyFpqBeSighf/zlXnQG/nl9AyBc6aAgy4XtxJMyt/CNyJpvPfzDD+bCc2zAOmhhqtF6TnmIaY+xV4mIrw==} + '@hono/node-server@2.0.2': + resolution: {integrity: sha512-tXlTi1h/4V7sDe7i97IVP+9re9ZU7wXZZggnR5ucCRclf1+AX6YhGStrR5w8bLj+3Mlyl0pKfBh9gqTqqnGKfQ==} engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -5333,13 +5333,13 @@ snapshots: protobufjs: 7.6.0 yargs: 17.7.2 - '@hono/node-server@2.0.1(hono@4.12.18)': + '@hono/node-server@2.0.2(hono@4.12.18)': dependencies: hono: 4.12.18 - '@hono/node-ws@1.3.1(@hono/node-server@2.0.1(hono@4.12.18))(hono@4.12.18)': + '@hono/node-ws@1.3.1(@hono/node-server@2.0.2(hono@4.12.18))(hono@4.12.18)': dependencies: - '@hono/node-server': 2.0.1(hono@4.12.18) + '@hono/node-server': 2.0.2(hono@4.12.18) hono: 4.12.18 ws: 8.20.1 transitivePeerDependencies: From ea961d9de5d3ad097ec4aeca4b1949cfb1543c07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:52:39 -0700 Subject: [PATCH 13/44] build(deps): bump @ghostery/adblocker-playwright from 2.15.0 to 2.17.0 (#470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@ghostery/adblocker-playwright](https://github.com/ghostery/adblocker/tree/HEAD/packages/adblocker-playwright) from 2.15.0 to 2.17.0.
Release notes

Sourced from @​ghostery/adblocker-playwright's releases.

v2.17.0

:rocket: New Feature

:house: Internal

:nut_and_bolt: Dependencies

  • Build(deps): Bump basic-ftp from 5.2.2 to 5.3.1 #5656 (@​dependabot[bot])
  • Build(deps-dev): Bump @​types/chrome from 0.1.40 to 0.1.42 #5661 (@​dependabot[bot] @​seia-soto)
  • @ghostery/adblocker-content, @ghostery/adblocker-electron-example, @ghostery/adblocker-electron-preload, @ghostery/adblocker-electron, @ghostery/adblocker-extended-selectors, @ghostery/adblocker-playwright-example, @ghostery/adblocker-playwright, @ghostery/adblocker-puppeteer-example, @ghostery/adblocker-puppeteer, @ghostery/adblocker-webextension-cosmetics, @ghostery/adblocker-webextension-example, @ghostery/adblocker-webextension, @ghostery/adblocker
  • @ghostery/adblocker-content, @ghostery/adblocker-electron-example, @ghostery/adblocker-electron, @ghostery/adblocker-extended-selectors, @ghostery/adblocker-playwright-example, @ghostery/adblocker-puppeteer-example, @ghostery/adblocker-puppeteer, @ghostery/adblocker-webextension-cosmetics, @ghostery/adblocker-webextension, @ghostery/adblocker

Authors: 4

v2.16.0

:running_woman: Performance

  • @ghostery/adblocker

:house: Internal

Authors: 3

Changelog

Sourced from @​ghostery/adblocker-playwright's changelog.

v2.17.0 (Fri May 08 2026)

:nut_and_bolt: Dependencies

Authors: 1


v2.16.0 (Fri May 08 2026)

:house: Internal

Authors: 1


v2.14.2 (Mon Apr 27 2026)

⚠️ Pushed to master

:house: Internal

:nut_and_bolt: Dependencies

Authors: 2


... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- packages/core/package.json | 2 +- pnpm-lock.yaml | 64 +++++++++++++++++++------------------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index fbf26192..76513d8a 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@ai-sdk/google-vertex": "^4.0.126", "@ai-sdk/openai": "^3.0.63", "@ai-sdk/openai-compatible": "^2.0.47", - "@ghostery/adblocker-playwright": "^2.14.1", + "@ghostery/adblocker-playwright": "^2.17.0", "@openrouter/ai-sdk-provider": "^2.8.0", "ai": "^6.0.177", "chalk": "^5.6.2", diff --git a/packages/core/package.json b/packages/core/package.json index 27504ab9..685b20e9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,7 +34,7 @@ "@ai-sdk/google-vertex": "^4.0.126", "@ai-sdk/openai": "^3.0.63", "@ai-sdk/openai-compatible": "^2.0.47", - "@ghostery/adblocker-playwright": "^2.14.1", + "@ghostery/adblocker-playwright": "^2.17.0", "@openrouter/ai-sdk-provider": "^2.8.0", "@tabstack/sdk": "^2.3.0", "ai": "^6.0.177", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24ef3b67..3aa6065d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^2.0.47 version: 2.0.47(zod@4.3.6) '@ghostery/adblocker-playwright': - specifier: ^2.14.1 - version: 2.15.0(playwright@1.59.1) + specifier: ^2.17.0 + version: 2.17.0(playwright@1.59.1) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) @@ -149,8 +149,8 @@ importers: specifier: ^2.0.47 version: 2.0.47(zod@4.3.6) '@ghostery/adblocker-playwright': - specifier: ^2.14.1 - version: 2.15.0(playwright@1.59.1) + specifier: ^2.17.0 + version: 2.17.0(playwright@1.59.1) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) @@ -1000,19 +1000,19 @@ packages: resolution: {integrity: sha512-PyUXQWB42s4jBli435TDiYuVsadwRHnMc27YaLouINktvTWsL3FcKrRMGawTayFk46X+n5bE23RjUTWQwrukWw==} engines: {node: '>= 0.10.0'} - '@ghostery/adblocker-content@2.15.0': - resolution: {integrity: sha512-CgQDRvpbqVQYEKveffFilUgy0QNRIYNxv64dTuuThZd0gTWebfg4LZF4H/lagow5GSoiDyXnZXzhSOTonkOZvg==} + '@ghostery/adblocker-content@2.17.3': + resolution: {integrity: sha512-Fm+hj0zhCkBzn3C3Ha/99dV0IRRW5gCP83ivya2JUgqb8styEBC62EGv6pPWOBEHqSvlLr4EygcWMlSZH4zISg==} - '@ghostery/adblocker-extended-selectors@2.15.0': - resolution: {integrity: sha512-exdYY2eKOXSyyd3cwfOjE2iq/8rOdkNgdkZ1Mm33361GkB2HzSrQWMczyGIlAc4CAviNV1aWu7Mlqph12WVTrg==} + '@ghostery/adblocker-extended-selectors@2.17.3': + resolution: {integrity: sha512-LlKTpvD8mscNb6NXPJ0yqLv4qvMP9ZRf33HNuQvQxY+nf4nU+ipYjhoNjvPrKAIrvHtIT4DhE+FeD4pAtp5GXQ==} - '@ghostery/adblocker-playwright@2.15.0': - resolution: {integrity: sha512-E/6aPnyXqryhEFYo2i4MceLM9Tk/98hteJhpUbQ2qiC7DWHS+gI7b0RIJjpkCKKIDohcSdN20AJUYW3c8OUqCA==} + '@ghostery/adblocker-playwright@2.17.0': + resolution: {integrity: sha512-pDt91Kxi5GCRB3KbKE317Y432kCAXIbbpykL2Ip5Q4QC5l0UeUB3kXfiBDbCtk/rhHPJCtIIJ+7By853zSzKOg==} peerDependencies: playwright: ^1.x - '@ghostery/adblocker@2.15.0': - resolution: {integrity: sha512-t64ecjJOeFyNHbxyYY2r1WA7aMFx8Pf94hXsYVbTKoCIiMjcOl4SlvMQ3obMlvNrj2QLNpByCD9f7Vq+7z45bQ==} + '@ghostery/adblocker@2.17.3': + resolution: {integrity: sha512-E2jcCtlkdJFm0gUH1GL+99CgAGudlOpMfpWJqKUL9lfPFbEuw//xypbyLUdTJcz31eBJaHq1ql163dIJ00/m1A==} '@ghostery/url-parser@1.3.1': resolution: {integrity: sha512-QKqGi+7aDQ4RcyHyCwgEk6B9vWnsBP4Q7htaN0zPJV3ATqTKEQDtSTb9c/AN586oJUDs24YXKcwFYwNweY/YjQ==} @@ -4386,11 +4386,11 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.30: - resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + tldts-core@7.4.0: + resolution: {integrity: sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==} - tldts-experimental@7.0.30: - resolution: {integrity: sha512-r5Y5PHuJg96xNfYJd71fxFT4EJvSs2gsZ1iilGR+xH1vwJpUWQ5CwT2ZwAZ0q8Lo9sdiijX2/zSQ6X+2YojGFg==} + tldts-experimental@7.4.0: + resolution: {integrity: sha512-F/xMOEOBEadHoaap73sz2utvE0vAvKv3h/a5vZYt3zLrJY51sGYIifbV2x8KNhENaU6xrTevydtVZjEr1iAfKQ==} tldts@7.0.30: resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} @@ -5294,32 +5294,32 @@ snapshots: '@fregante/relaxed-json@2.0.0': {} - '@ghostery/adblocker-content@2.15.0': + '@ghostery/adblocker-content@2.17.3': dependencies: - '@ghostery/adblocker-extended-selectors': 2.15.0 + '@ghostery/adblocker-extended-selectors': 2.17.3 - '@ghostery/adblocker-extended-selectors@2.15.0': {} + '@ghostery/adblocker-extended-selectors@2.17.3': {} - '@ghostery/adblocker-playwright@2.15.0(playwright@1.59.1)': + '@ghostery/adblocker-playwright@2.17.0(playwright@1.59.1)': dependencies: - '@ghostery/adblocker': 2.15.0 - '@ghostery/adblocker-content': 2.15.0 + '@ghostery/adblocker': 2.17.3 + '@ghostery/adblocker-content': 2.17.3 playwright: 1.59.1 - tldts-experimental: 7.0.30 + tldts-experimental: 7.4.0 - '@ghostery/adblocker@2.15.0': + '@ghostery/adblocker@2.17.3': dependencies: - '@ghostery/adblocker-content': 2.15.0 - '@ghostery/adblocker-extended-selectors': 2.15.0 + '@ghostery/adblocker-content': 2.17.3 + '@ghostery/adblocker-extended-selectors': 2.17.3 '@ghostery/url-parser': 1.3.1 '@remusao/guess-url-type': 2.1.0 '@remusao/small': 2.1.0 '@remusao/smaz': 2.2.0 - tldts-experimental: 7.0.30 + tldts-experimental: 7.4.0 '@ghostery/url-parser@1.3.1': dependencies: - tldts-experimental: 7.0.30 + tldts-experimental: 7.4.0 '@grpc/grpc-js@1.14.3': dependencies: @@ -8650,15 +8650,15 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.0.30: {} + tldts-core@7.4.0: {} - tldts-experimental@7.0.30: + tldts-experimental@7.4.0: dependencies: - tldts-core: 7.0.30 + tldts-core: 7.4.0 tldts@7.0.30: dependencies: - tldts-core: 7.0.30 + tldts-core: 7.4.0 tmp@0.2.5: {} From 2228bcfac412c00a100cd250903ff15333827fdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:53:04 -0700 Subject: [PATCH 14/44] build(deps-dev): bump @vitest/coverage-v8 from 4.1.5 to 4.1.6 (#471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.5 to 4.1.6.
Release notes

Sourced from @​vitest/coverage-v8's releases.

v4.1.6

   🐞 Bug Fixes

   🏎 Performance

    View changes on GitHub
Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/core/package.json | 2 +- pnpm-lock.yaml | 95 +++++++++----------------------------- 2 files changed, 24 insertions(+), 73 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 685b20e9..1f3d2198 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,7 +63,7 @@ "@types/jsdom": "^28.0.1", "@types/node": "^25.7.0", "@types/turndown": "^5.0.6", - "@vitest/coverage-v8": "^4.1.4", + "@vitest/coverage-v8": "^4.1.6", "esbuild": "^0.28.0", "jsdom": "^29.0.2", "ts-json-schema-generator": "2.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3aa6065d..d49c292f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,7 +132,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/core: dependencies: @@ -207,8 +207,8 @@ importers: specifier: ^5.0.6 version: 5.0.6 '@vitest/coverage-v8': - specifier: ^4.1.4 - version: 4.1.5(vitest@4.1.6) + specifier: ^4.1.6 + version: 4.1.6(vitest@4.1.6) esbuild: specifier: ^0.28.0 version: 0.28.0 @@ -226,7 +226,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/extension: dependencies: @@ -347,7 +347,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) wxt: specifier: 0.20.26 version: 0.20.26(@types/node@25.9.1)(eslint@9.39.4(jiti@2.7.0))(jiti@2.7.0)(rolldown@1.0.2)(rollup@4.60.3)(tsx@4.21.0)(yaml@2.8.4) @@ -405,7 +405,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -500,10 +500,6 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -516,11 +512,6 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -538,10 +529,6 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -2310,11 +2297,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/coverage-v8@4.1.5': - resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} peerDependencies: - '@vitest/browser': 4.1.5 - vitest: 4.1.5 + '@vitest/browser': 4.1.6 + vitest: 4.1.6 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2333,9 +2320,6 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.5': - resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - '@vitest/pretty-format@4.1.6': resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} @@ -2348,9 +2332,6 @@ packages: '@vitest/spy@4.1.6': resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} - '@vitest/utils@4.1.5': - resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} - '@vitest/utils@4.1.6': resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} @@ -2491,8 +2472,8 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@1.0.0: - resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + ast-v8-to-istanbul@1.0.2: + resolution: {integrity: sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==} async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -3662,9 +3643,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} - magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} @@ -4971,18 +4949,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -4993,11 +4965,6 @@ snapshots: '@babel/runtime@7.29.7': {} - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -6522,19 +6489,19 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) - '@vitest/coverage-v8@4.1.5(vitest@4.1.6)': + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.5 - ast-v8-to-istanbul: 1.0.0 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.2 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.2 + magicast: 0.5.3 obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/expect@4.1.6': dependencies: @@ -6561,10 +6528,6 @@ snapshots: optionalDependencies: vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.5': - dependencies: - tinyrainbow: 3.1.0 - '@vitest/pretty-format@4.1.6': dependencies: tinyrainbow: 3.1.0 @@ -6583,12 +6546,6 @@ snapshots: '@vitest/spy@4.1.6': {} - '@vitest/utils@4.1.5': - dependencies: - '@vitest/pretty-format': 4.1.5 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - '@vitest/utils@4.1.6': dependencies: '@vitest/pretty-format': 4.1.6 @@ -6751,7 +6708,7 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@1.0.0: + ast-v8-to-istanbul@1.0.2: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -7892,12 +7849,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.2: - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - magicast@0.5.3: dependencies: '@babel/parser': 7.29.7 @@ -7906,7 +7857,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 make-error@1.3.6: {} @@ -8881,7 +8832,7 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 - vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) @@ -8906,13 +8857,13 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.1 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.6) + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) happy-dom: 20.9.0 jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) @@ -8937,7 +8888,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.1 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.6) + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) happy-dom: 20.9.0 jsdom: 29.1.1 transitivePeerDependencies: From b1a0ddf1ea01a54321b427f708ea6df71c6a954a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:53:15 -0700 Subject: [PATCH 15/44] build(deps): bump @tailwindcss/vite from 4.2.4 to 4.3.0 (#473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.2.4 to 4.3.0.
Release notes

Sourced from @​tailwindcss/vite's releases.

v4.3.0

Added

  • Add @container-size utility (#18901)
  • Add scrollbar-{auto,thin,none} utilities for scrollbar-width, and scrollbar-thumb-* / scrollbar-track-* color utilities for scrollbar-color (#19981, #20019)
  • Add scrollbar-gutter-* utilities (#20018)
  • Add zoom-* utilities (#20020)
  • Add tab-* utilities (#20022)
  • Allow using @variant with stacked variants (e.g. @variant hover:focus { … }) (#19996)
  • Allow using @variant with compound variants (e.g. @variant hover, focus { … }) (#19996)
  • Support --default(…) in --value(…) and --modifier(…) for functional @utility definitions (#19989)

Fixed

  • Ensure @plugin resolves package JavaScript entries instead of browser CSS entries when using @tailwindcss/vite (#19949)
  • Fix relative @import and @plugin paths resolving from the wrong directory when using @tailwindcss/vite (#19965)
  • Ensure CSS files containing @variant are processed by @tailwindcss/vite (#19966)
  • Resolve imports relative to base when result.opts.from is not provided when using @tailwindcss/postcss (#19980)
  • Canonicalization: preserve significant _ whitespace in arbitrary values (#19986)
  • Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability (e.g. w-[calc(100%---spacing(60))]w-[calc(100%-(--spacing(60)))]) (#19986)
  • Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. -mt-[20in]mt-[-20in], not mt-[-1920px]) (#19988)
  • Canonicalization: migrate arbitrary :has() variants from [&:has(…)] to has-[…] (#19991)
  • Upgrade: don’t migrate inline style attributes (e.g. style="flex-grow: 1"style="flex-grow: 1", not style="grow: 1") (#19918)
  • Allow multiple @utility definitions with the same name but different value types (#19777)
  • Export missing PluginWithConfig type from tailwindcss/plugin to fix errors when inferring plugin config types (#19707)
  • Ensure start and end legacy utilities without values do not generate CSS (#20003)
  • Ensure --value(…) is required in functional @utility definitions (#20005)
  • Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. -left-[(var(--a)+var(--b))]) (#20011)
Changelog

Sourced from @​tailwindcss/vite's changelog.

[4.3.0] - 2026-05-08

Added

  • Add @container-size utility (#18901)
  • Add scrollbar-{auto,thin,none} utilities for scrollbar-width, and scrollbar-thumb-* / scrollbar-track-* color utilities for scrollbar-color (#19981, #20019)
  • Add scrollbar-gutter-* utilities (#20018)
  • Add zoom-* utilities (#20020)
  • Add tab-* utilities (#20022)
  • Allow using @variant with stacked variants (e.g. @variant hover:focus { … }) (#19996)
  • Allow using @variant with compound variants (e.g. @variant hover, focus { … }) (#19996)
  • Support --default(…) in --value(…) and --modifier(…) for functional @utility definitions (#19989)

Fixed

  • Ensure @plugin resolves package JavaScript entries instead of browser CSS entries when using @tailwindcss/vite (#19949)
  • Fix relative @import and @plugin paths resolving from the wrong directory when using @tailwindcss/vite (#19965)
  • Ensure CSS files containing @variant are processed by @tailwindcss/vite (#19966)
  • Resolve imports relative to base when result.opts.from is not provided when using @tailwindcss/postcss (#19980)
  • Canonicalization: preserve significant _ whitespace in arbitrary values (#19986)
  • Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability (e.g. w-[calc(100%---spacing(60))]w-[calc(100%-(--spacing(60)))]) (#19986)
  • Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. -mt-[20in]mt-[-20in], not mt-[-1920px]) (#19988)
  • Canonicalization: migrate arbitrary :has() variants from [&:has(…)] to has-[…] (#19991)
  • Upgrade: don’t migrate inline style attributes (e.g. style="flex-grow: 1"style="flex-grow: 1", not style="grow: 1") (#19918)
  • Allow multiple @utility definitions with the same name but different value types (#19777)
  • Export missing PluginWithConfig type from tailwindcss/plugin to fix errors when inferring plugin config types (#19707)
  • Ensure start and end legacy utilities without values do not generate CSS (#20003)
  • Ensure --value(…) is required in functional @utility definitions (#20005)
  • Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. -left-[(var(--a)+var(--b))]) (#20011)
Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/extension/package.json | 2 +- pnpm-lock.yaml | 143 +++++++++++++++----------------- 2 files changed, 70 insertions(+), 75 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 3f06bd30..abef1cf5 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -29,7 +29,7 @@ "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "^4.3.0", "@types/webextension-polyfill": "^0.12.5", "@wxt-dev/webextension-polyfill": "^1.0.0", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d49c292f..8359851a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,8 +255,8 @@ importers: specifier: ^1.2.3 version: 1.2.4(@types/react@19.2.14)(react@19.2.6) '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.4(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) '@types/webextension-polyfill': specifier: ^0.12.5 version: 0.12.5 @@ -2096,65 +2096,65 @@ packages: '@tabstack/sdk@2.6.1': resolution: {integrity: sha512-n65NT/wTSP7r25nJU/GAQqx2wg2OGkorgoxppJ7xIxjPxq1hRqlja40wn2RsZnVne0kc2gvkBg9Q4cMxureeMg==} - '@tailwindcss/node@4.2.4': - resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - '@tailwindcss/oxide-android-arm64@4.2.4': - resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.4': - resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.4': - resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.4': - resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': - resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': - resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': - resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': - resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.2.4': - resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.2.4': - resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2165,24 +2165,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': - resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': - resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.4': - resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.2.4': - resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -2862,8 +2862,8 @@ packages: encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - enhanced-resolve@5.21.0: - resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + enhanced-resolve@5.22.0: + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -4319,9 +4319,6 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.4: - resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} @@ -6317,72 +6314,72 @@ snapshots: '@tabstack/sdk@2.6.1': {} - '@tailwindcss/node@4.2.4': + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.0 + enhanced-resolve: 5.22.0 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.4 + tailwindcss: 4.3.0 - '@tailwindcss/oxide-android-arm64@4.2.4': + '@tailwindcss/oxide-android-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.4': + '@tailwindcss/oxide-darwin-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.4': + '@tailwindcss/oxide-darwin-x64@4.3.0': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.4': + '@tailwindcss/oxide-freebsd-x64@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.4': + '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.4': + '@tailwindcss/oxide-wasm32-wasi@4.3.0': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': optional: true - '@tailwindcss/oxide@4.2.4': + '@tailwindcss/oxide@4.3.0': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-arm64': 4.2.4 - '@tailwindcss/oxide-darwin-x64': 4.2.4 - '@tailwindcss/oxide-freebsd-x64': 4.2.4 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 - '@tailwindcss/oxide-linux-x64-musl': 4.2.4 - '@tailwindcss/oxide-wasm32-wasi': 4.2.4 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - - '@tailwindcss/vite@4.2.4(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': - dependencies: - '@tailwindcss/node': 4.2.4 - '@tailwindcss/oxide': 4.2.4 - tailwindcss: 4.2.4 + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 vite: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) '@testing-library/dom@10.4.1': @@ -7077,7 +7074,7 @@ snapshots: iconv-lite: 0.6.3 whatwg-encoding: 3.1.1 - enhanced-resolve@5.21.0: + enhanced-resolve@5.22.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -8564,8 +8561,6 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.4: {} - tailwindcss@4.3.0: {} tapable@2.3.3: {} From 3bda46b5d43d755e04b1d3659dc4efe8788a863f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 11:53:28 -0700 Subject: [PATCH 16/44] build(deps): bump tailwind-merge from 3.5.0 to 3.6.0 (#475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [tailwind-merge](https://github.com/dcastil/tailwind-merge) from 3.5.0 to 3.6.0.
Release notes

Sourced from tailwind-merge's releases.

v3.6.0

New Features

Documentation

Other

Full Changelog: https://github.com/dcastil/tailwind-merge/compare/v3.5.0...v3.6.0

Thanks to @​brandonmcconnell, @​manavm1990, @​langy, @​roboflow, @​syntaxfm, @​getsentry, @​codecov, a private sponsor, @​block, @​openclaw, @​sourcegraph, @​mike-healy and more via @​thnxdev for sponsoring tailwind-merge! ❤️

Commits
  • d54f7e5 v3.6.0
  • 638871a Update README to add info about Tailwind CSS v4.3 support
  • 39fc7b5 Revert "v3.6.0"
  • bd8390f v3.6.0
  • 802877c add v3.6.0 changelog
  • a35feda Merge pull request #665 from dcastil/renovate/rollup-plugin-babel-7.x
  • 940389c Merge pull request #667 from dcastil/renovate/release-drafter-release-drafter...
  • 005af6d pin to specific version
  • 5816ced implement breaking changes
  • 17041e1 Merge pull request #676 from dcastil/dependabot/npm_and_yarn/babel/plugin-tra...
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/extension/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index abef1cf5..67ef529c 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -40,7 +40,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "pilo-core": "workspace:*", - "tailwind-merge": "^3.3.0", + "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "turndown": "^7.2.2", "webextension-polyfill": "^0.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8359851a..d68adaa0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,8 +288,8 @@ importers: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) tailwind-merge: - specifier: ^3.3.0 - version: 3.5.0 + specifier: ^3.6.0 + version: 3.6.0 tailwindcss: specifier: ^4.3.0 version: 4.3.0 @@ -4316,8 +4316,8 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} @@ -8559,7 +8559,7 @@ snapshots: symbol-tree@3.2.4: {} - tailwind-merge@3.5.0: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.0: {} From bdcaa29aaed8728aed4ae9e6a86fed01007904e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 12:00:15 -0700 Subject: [PATCH 17/44] build(deps): bump playwright from 1.59.1 to 1.60.0 (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [playwright](https://github.com/microsoft/playwright) from 1.59.1 to 1.60.0.
Release notes

Sourced from playwright's releases.

v1.60.0

🌐 HAR recording on Tracing

tracing.startHar() / tracing.stopHar() expose HAR recording as a first-class tracing API, with the same content, mode and urlFilter options as recordHar. The returned Disposable makes it easy to scope a recording with await using:

await using har = await
context.tracing.startHar('trace.har');
const page = await context.newPage();
await page.goto('https://playwright.dev');
// HAR is finalized when `har` goes out of scope.

🪝 Drop API

New locator.drop() simulates an external drag-and-drop of files or clipboard-like data onto an element. Playwright dispatches dragenter, dragover, and drop with a synthetic [DataTransfer] in the page context — works cross-browser and is great for testing upload zones:

await page.locator('#dropzone').drop({
files: { name: 'note.txt', mimeType: 'text/plain', buffer:
Buffer.from('hello') },
});

await page.locator('#dropzone').drop({ data: { 'text/plain': 'hello world', 'text/uri-list': 'https://example.com', }, });

🎯 Aria snapshots

🛑 test.abort()

New test.abort() aborts the currently running test from a fixture, hook, or route handler with an optional message. Use it when you have detected an unrecoverable misuse and want to fail the test right away:

test('does not publish to the shared page', async
({ page }) => {
  await page.route('**/publish', route => {
test.abort('Tests must not publish to the shared page. Use the `clone`
option.');
    return route.abort();
  });
  // ...
});

New APIs

Browser, Context and Page

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- packages/core/package.json | 2 +- pnpm-lock.yaml | 34 ++++++++++++++++++++++++++-------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 76513d8a..ecfd788f 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "liquidjs": "^10.25.7", "nanoid": "^5.1.7", "ollama-ai-provider-v2": "^3.5.0", - "playwright": "1.59.1", + "playwright": "1.60.0", "turndown": "^7.2.2", "web-ext": "^10.0.0", "zod": "^4.3.6" diff --git a/packages/core/package.json b/packages/core/package.json index 1f3d2198..389e86c7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ "liquidjs": "^10.25.7", "nanoid": "^5.1.7", "ollama-ai-provider-v2": "^3.5.0", - "playwright": "1.59.1", + "playwright": "1.60.0", "turndown": "^7.2.2", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d68adaa0..67687f2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 2.0.47(zod@4.3.6) '@ghostery/adblocker-playwright': specifier: ^2.17.0 - version: 2.17.0(playwright@1.59.1) + version: 2.17.0(playwright@1.60.0) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) @@ -57,8 +57,8 @@ importers: specifier: ^3.5.0 version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) playwright: - specifier: 1.59.1 - version: 1.59.1 + specifier: 1.60.0 + version: 1.60.0 turndown: specifier: ^7.2.2 version: 7.2.4 @@ -150,7 +150,7 @@ importers: version: 2.0.47(zod@4.3.6) '@ghostery/adblocker-playwright': specifier: ^2.17.0 - version: 2.17.0(playwright@1.59.1) + version: 2.17.0(playwright@1.60.0) '@openrouter/ai-sdk-provider': specifier: ^2.8.0 version: 2.9.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) @@ -185,8 +185,8 @@ importers: specifier: ^3.5.0 version: 3.5.0(ai@6.0.177(zod@4.3.6))(zod@4.3.6) playwright: - specifier: 1.59.1 - version: 1.59.1 + specifier: 1.60.0 + version: 1.60.0 turndown: specifier: ^7.2.2 version: 7.2.4 @@ -3922,11 +3922,21 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + playwright@1.59.1: resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} engines: {node: '>=18'} hasBin: true + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -5264,11 +5274,11 @@ snapshots: '@ghostery/adblocker-extended-selectors@2.17.3': {} - '@ghostery/adblocker-playwright@2.17.0(playwright@1.59.1)': + '@ghostery/adblocker-playwright@2.17.0(playwright@1.60.0)': dependencies: '@ghostery/adblocker': 2.17.3 '@ghostery/adblocker-content': 2.17.3 - playwright: 1.59.1 + playwright: 1.60.0 tldts-experimental: 7.4.0 '@ghostery/adblocker@2.17.3': @@ -8141,12 +8151,20 @@ snapshots: playwright-core@1.59.1: {} + playwright-core@1.60.0: {} + playwright@1.59.1: dependencies: playwright-core: 1.59.1 optionalDependencies: fsevents: 2.3.2 + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 From 0df797da7954247b0d9ab32903adedd00f8e9043 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Wed, 27 May 2026 13:15:25 -0400 Subject: [PATCH 18/44] fix(core): normalize metadata browser errors --- .../core/src/browser/playwrightBrowser.ts | 312 +++++++------- packages/core/src/core.ts | 2 + packages/core/test/playwrightBrowser.test.ts | 36 ++ .../src/background/ExtensionBrowser.ts | 407 ++++++++++-------- .../extension/test/ExtensionBrowser.test.ts | 59 +++ 5 files changed, 501 insertions(+), 315 deletions(-) diff --git a/packages/core/src/browser/playwrightBrowser.ts b/packages/core/src/browser/playwrightBrowser.ts index e181d646..8a7cb3c5 100644 --- a/packages/core/src/browser/playwrightBrowser.ts +++ b/packages/core/src/browser/playwrightBrowser.ts @@ -799,176 +799,194 @@ export class PlaywrightBrowser implements AriaBrowser { async getFieldMetadata(ref: string): Promise { const locator = await this.validateElementRef(ref); - return locator.evaluate((element, elementRef): FieldMetadata => { - const el = element as HTMLElement; - const input = el instanceof HTMLInputElement ? el : null; - const form = getElementForm(el); - - return { - ref: elementRef, - tagName: el.tagName.toLowerCase(), - inputType: input?.type?.toLowerCase() ?? null, - role: el.getAttribute("role"), - name: getElementName(el), - label: getElementLabel(el), - placeholder: getElementPlaceholder(el), - autocomplete: getElementAutocomplete(el), - isContentEditable: el.isContentEditable, - formId: form?.id || null, - formAction: form?.action || null, - formMethod: form?.method?.toLowerCase() || null, - }; + try { + return await locator.evaluate((element, elementRef): FieldMetadata => { + const el = element as HTMLElement; + const input = el instanceof HTMLInputElement ? el : null; + const form = getElementForm(el); - function getElementForm(node: HTMLElement): HTMLFormElement | null { - if ( - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement || - node instanceof HTMLSelectElement || - node instanceof HTMLButtonElement - ) { - return node.form; - } - return node.closest("form"); - } + return { + ref: elementRef, + tagName: el.tagName.toLowerCase(), + inputType: input?.type?.toLowerCase() ?? null, + role: el.getAttribute("role"), + name: getElementName(el), + label: getElementLabel(el), + placeholder: getElementPlaceholder(el), + autocomplete: getElementAutocomplete(el), + isContentEditable: el.isContentEditable, + formId: form?.id || null, + formAction: form?.action || null, + formMethod: form?.method?.toLowerCase() || null, + }; - function getElementName(node: HTMLElement): string | null { - if ( - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement || - node instanceof HTMLSelectElement || - node instanceof HTMLButtonElement - ) { - return node.name || null; + function getElementForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.form; + } + return node.closest("form"); } - return node.getAttribute("name"); - } - function getElementLabel(node: HTMLElement): string | null { - const ariaLabel = node.getAttribute("aria-label"); - if (ariaLabel?.trim()) return ariaLabel.trim(); - - const labelledBy = node.getAttribute("aria-labelledby"); - if (labelledBy) { - const text = labelledBy - .split(/\s+/) - .map((id) => node.ownerDocument.getElementById(id)?.textContent?.trim() || "") - .filter(Boolean) - .join(" "); - if (text) return text; + function getElementName(node: HTMLElement): string | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.name || null; + } + return node.getAttribute("name"); } - if ("labels" in node) { - const labels = (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) - .labels; - const text = Array.from(labels || []) - .map((label) => label.textContent?.trim() || "") - .filter(Boolean) - .join(" "); - if (text) return text; - } + function getElementLabel(node: HTMLElement): string | null { + const ariaLabel = node.getAttribute("aria-label"); + if (ariaLabel?.trim()) return ariaLabel.trim(); + + const labelledBy = node.getAttribute("aria-labelledby"); + if (labelledBy) { + const text = labelledBy + .split(/\s+/) + .map((id) => node.ownerDocument.getElementById(id)?.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } - return null; - } + if ("labels" in node) { + const labels = (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) + .labels; + const text = Array.from(labels || []) + .map((label) => label.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } - function getElementPlaceholder(node: HTMLElement): string | null { - if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { - return node.placeholder || null; + return null; } - return null; - } - function getElementAutocomplete(node: HTMLElement): string | null { - if ( - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement || - node instanceof HTMLSelectElement - ) { - return node.autocomplete || null; + function getElementPlaceholder(node: HTMLElement): string | null { + if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { + return node.placeholder || null; + } + return null; } - return null; - } - }, ref); - } - - async getFormSubmissionContext( - ref: string, - trigger: FormSubmissionTrigger = "click", - ): Promise { - const locator = await this.validateElementRef(ref); - - return locator.evaluate( - (element, { submitterRef, trigger }): FormSubmissionContext | null => { - const el = element as HTMLElement; - if (!canSubmitForm(el, trigger)) return null; - - const form = getSubmissionForm(el); - if (!form) return null; - - const fields = Array.from(form.elements) - .filter( - (field): field is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement => - field instanceof HTMLInputElement || - field instanceof HTMLTextAreaElement || - field instanceof HTMLSelectElement, - ) - .filter((field) => !field.disabled) - .map((field) => ({ - ref: field.getAttribute("data-pilo-ref"), - name: field.name || null, - tagName: field.tagName.toLowerCase(), - inputType: field instanceof HTMLInputElement ? field.type.toLowerCase() : null, - autocomplete: "autocomplete" in field ? field.autocomplete || null : null, - })); - return { - submitterRef, - formId: form.id || null, - actionUrl: form.action || null, - method: form.method?.toLowerCase() || null, - fields, - }; - - function getSubmissionForm(node: HTMLElement): HTMLFormElement | null { + function getElementAutocomplete(node: HTMLElement): string | null { if ( - node instanceof HTMLButtonElement || node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement ) { - return node.form; + return node.autocomplete || null; } - return node.closest("form"); + return null; } + }, ref); + } catch (error) { + throw new BrowserActionException( + "getFieldMetadata", + `Failed to get field metadata: ${error instanceof Error ? error.message : String(error)}`, + { ref, originalError: error }, + ); + } + } - function canSubmitForm(node: HTMLElement, submitTrigger: FormSubmissionTrigger): boolean { - if (submitTrigger === "click") { - if (node instanceof HTMLButtonElement) { - return node.type === "submit"; - } - if (node instanceof HTMLInputElement) { - return node.type === "submit" || node.type === "image"; + async getFormSubmissionContext( + ref: string, + trigger: FormSubmissionTrigger = "click", + ): Promise { + const locator = await this.validateElementRef(ref); + + try { + return await locator.evaluate( + (element, { submitterRef, trigger }): FormSubmissionContext | null => { + const el = element as HTMLElement; + if (!canSubmitForm(el, trigger)) return null; + + const form = getSubmissionForm(el); + if (!form) return null; + + const fields = Array.from(form.elements) + .filter( + (field): field is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement => + field instanceof HTMLInputElement || + field instanceof HTMLTextAreaElement || + field instanceof HTMLSelectElement, + ) + .filter((field) => !field.disabled) + .map((field) => ({ + ref: field.getAttribute("data-pilo-ref"), + name: field.name || null, + tagName: field.tagName.toLowerCase(), + inputType: field instanceof HTMLInputElement ? field.type.toLowerCase() : null, + autocomplete: "autocomplete" in field ? field.autocomplete || null : null, + })); + + return { + submitterRef, + formId: form.id || null, + actionUrl: form.action || null, + method: form.method?.toLowerCase() || null, + fields, + }; + + function getSubmissionForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLButtonElement || + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement + ) { + return node.form; } - return false; + return node.closest("form"); } - if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) - return false; - if (!(node instanceof HTMLInputElement)) return false; - return ![ - "button", - "checkbox", - "color", - "file", - "hidden", - "radio", - "range", - "reset", - "submit", - ].includes(node.type); - } - }, - { submitterRef: ref, trigger }, - ); + function canSubmitForm(node: HTMLElement, submitTrigger: FormSubmissionTrigger): boolean { + if (submitTrigger === "click") { + if (node instanceof HTMLButtonElement) { + return node.type === "submit"; + } + if (node instanceof HTMLInputElement) { + return node.type === "submit" || node.type === "image"; + } + return false; + } + + if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) + return false; + if (!(node instanceof HTMLInputElement)) return false; + return ![ + "button", + "checkbox", + "color", + "file", + "hidden", + "radio", + "range", + "reset", + "submit", + ].includes(node.type); + } + }, + { submitterRef: ref, trigger }, + ); + } catch (error) { + throw new BrowserActionException( + "getFormSubmissionContext", + `Failed to get form submission context: ${ + error instanceof Error ? error.message : String(error) + }`, + { ref, trigger, originalError: error }, + ); + } } async performAction(ref: string, action: PageAction, value?: string): Promise { diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index e1f7e0d6..a63d4b25 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -57,6 +57,8 @@ export type { Action, TaskValidationResult } from "./schemas.js"; export { RecoverableError, BrowserException, + BrowserActionException, + InvalidRefException, NavigationTimeoutException, PlanningError, NoStartingUrlError, diff --git a/packages/core/test/playwrightBrowser.test.ts b/packages/core/test/playwrightBrowser.test.ts index ec49f9d6..0350577a 100644 --- a/packages/core/test/playwrightBrowser.test.ts +++ b/packages/core/test/playwrightBrowser.test.ts @@ -854,6 +854,42 @@ describe("PlaywrightBrowser", () => { expect(error.ref).toBe("missing"); }); }); + + describe("metadata error handling", () => { + it("should wrap field metadata evaluation errors in BrowserActionException", async () => { + const mockLocator = { + count: vi.fn().mockResolvedValue(1), + evaluate: vi.fn().mockRejectedValue(new Error("Execution context was destroyed")), + }; + const mockPage = { + locator: vi.fn().mockReturnValue(mockLocator), + }; + (browser as any).page = mockPage; + + await expect(browser.getFieldMetadata("input1")).rejects.toThrow(BrowserActionException); + await expect(browser.getFieldMetadata("input1")).rejects.toThrow( + "Failed to get field metadata: Execution context was destroyed", + ); + }); + + it("should wrap form submission context evaluation errors in BrowserActionException", async () => { + const mockLocator = { + count: vi.fn().mockResolvedValue(1), + evaluate: vi.fn().mockRejectedValue(new Error("Execution context was destroyed")), + }; + const mockPage = { + locator: vi.fn().mockReturnValue(mockLocator), + }; + (browser as any).page = mockPage; + + await expect(browser.getFormSubmissionContext("submit1")).rejects.toThrow( + BrowserActionException, + ); + await expect(browser.getFormSubmissionContext("submit1")).rejects.toThrow( + "Failed to get form submission context: Execution context was destroyed", + ); + }); + }); }); describe("CDP endpoint failover", () => { diff --git a/packages/extension/src/background/ExtensionBrowser.ts b/packages/extension/src/background/ExtensionBrowser.ts index db4920ab..65cdd47e 100644 --- a/packages/extension/src/background/ExtensionBrowser.ts +++ b/packages/extension/src/background/ExtensionBrowser.ts @@ -5,7 +5,7 @@ import type { FormSubmissionContext, FormSubmissionTrigger, } from "pilo-core/core"; -import { PageAction, LoadState } from "pilo-core/core"; +import { BrowserActionException, InvalidRefException, PageAction, LoadState } from "pilo-core/core"; import type { Tabs } from "webextension-polyfill"; import { createLogger } from "../shared/utils/logger"; import TurndownService from "turndown"; @@ -16,6 +16,10 @@ interface ActionResult { message?: string; } +type MetadataScriptResult = + | { success: true; data: T } + | { success: false; error: string; errorType?: "invalid-ref" }; + interface AriaSnapshotWindow { generateAndRenderAriaTree: (root: Element, counter?: { value: number }) => string; } @@ -308,198 +312,265 @@ export class ExtensionBrowser implements AriaBrowser { } async getFieldMetadata(ref: string): Promise { - const tab = await this.getActiveTab(); - await this.ensureContentScript(); - - const [{ result }] = await browser.scripting.executeScript({ - target: { tabId: tab.id! }, - func: (elementRef: string) => { - const element = document.querySelector(`[data-pilo-ref="${elementRef}"]`); - if (!(element instanceof HTMLElement)) { - throw new Error(`Element with ref ${elementRef} not found in DOM`); - } + try { + const tab = await this.getActiveTab(); + await this.ensureContentScript(); - const input = element instanceof HTMLInputElement ? element : null; - const form = getElementForm(element); - - return { - ref: elementRef, - tagName: element.tagName.toLowerCase(), - inputType: input?.type?.toLowerCase() ?? null, - role: element.getAttribute("role"), - name: getElementName(element), - label: getElementLabel(element), - placeholder: getElementPlaceholder(element), - autocomplete: getElementAutocomplete(element), - isContentEditable: element.isContentEditable, - formId: form?.id || null, - formAction: form?.action || null, - formMethod: form?.method?.toLowerCase() || null, - }; - - function getElementForm(node: HTMLElement): HTMLFormElement | null { - if ( - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement || - node instanceof HTMLSelectElement || - node instanceof HTMLButtonElement - ) { - return node.form; + const [{ result }] = await browser.scripting.executeScript({ + target: { tabId: tab.id! }, + func: (elementRef: string): MetadataScriptResult => { + const element = document.querySelector(`[data-pilo-ref="${elementRef}"]`); + if (!(element instanceof HTMLElement)) { + return { + success: false, + error: `Element with ref ${elementRef} not found in DOM`, + errorType: "invalid-ref", + }; } - return node.closest("form"); - } - function getElementName(node: HTMLElement): string | null { - if ( - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement || - node instanceof HTMLSelectElement || - node instanceof HTMLButtonElement - ) { - return node.name || null; + const input = element instanceof HTMLInputElement ? element : null; + const form = getElementForm(element); + + return { + success: true, + data: { + ref: elementRef, + tagName: element.tagName.toLowerCase(), + inputType: input?.type?.toLowerCase() ?? null, + role: element.getAttribute("role"), + name: getElementName(element), + label: getElementLabel(element), + placeholder: getElementPlaceholder(element), + autocomplete: getElementAutocomplete(element), + isContentEditable: element.isContentEditable, + formId: form?.id || null, + formAction: form?.action || null, + formMethod: form?.method?.toLowerCase() || null, + }, + }; + + function getElementForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.form; + } + return node.closest("form"); } - return node.getAttribute("name"); - } - function getElementLabel(node: HTMLElement): string | null { - const ariaLabel = node.getAttribute("aria-label"); - if (ariaLabel?.trim()) return ariaLabel.trim(); - - const labelledBy = node.getAttribute("aria-labelledby"); - if (labelledBy) { - const text = labelledBy - .split(/\s+/) - .map((id) => node.ownerDocument.getElementById(id)?.textContent?.trim() || "") - .filter(Boolean) - .join(" "); - if (text) return text; + function getElementName(node: HTMLElement): string | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement || + node instanceof HTMLButtonElement + ) { + return node.name || null; + } + return node.getAttribute("name"); } - if ("labels" in node) { - const labels = (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) - .labels; - const text = Array.from(labels || []) - .map((label) => label.textContent?.trim() || "") - .filter(Boolean) - .join(" "); - if (text) return text; - } + function getElementLabel(node: HTMLElement): string | null { + const ariaLabel = node.getAttribute("aria-label"); + if (ariaLabel?.trim()) return ariaLabel.trim(); + + const labelledBy = node.getAttribute("aria-labelledby"); + if (labelledBy) { + const text = labelledBy + .split(/\s+/) + .map((id) => node.ownerDocument.getElementById(id)?.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } - return null; - } + if ("labels" in node) { + const labels = (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) + .labels; + const text = Array.from(labels || []) + .map((label) => label.textContent?.trim() || "") + .filter(Boolean) + .join(" "); + if (text) return text; + } - function getElementPlaceholder(node: HTMLElement): string | null { - if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { - return node.placeholder || null; + return null; } - return null; - } - function getElementAutocomplete(node: HTMLElement): string | null { - if ( - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement || - node instanceof HTMLSelectElement - ) { - return node.autocomplete || null; + function getElementPlaceholder(node: HTMLElement): string | null { + if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { + return node.placeholder || null; + } + return null; } - return null; - } - }, - args: [ref], - }); - return result as FieldMetadata; + function getElementAutocomplete(node: HTMLElement): string | null { + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement + ) { + return node.autocomplete || null; + } + return null; + } + }, + args: [ref], + }); + + return this.unwrapMetadataResult( + result as MetadataScriptResult | undefined, + ref, + "getFieldMetadata", + ); + } catch (error) { + if (error instanceof InvalidRefException || error instanceof BrowserActionException) { + throw error; + } + throw new BrowserActionException( + "getFieldMetadata", + `Failed to get field metadata: ${error instanceof Error ? error.message : String(error)}`, + { ref, originalError: error }, + ); + } } async getFormSubmissionContext( ref: string, trigger: FormSubmissionTrigger = "click", ): Promise { - const tab = await this.getActiveTab(); - await this.ensureContentScript(); - - const [{ result }] = await browser.scripting.executeScript({ - target: { tabId: tab.id! }, - func: (paramsJson: string) => { - const { ref: submitterRef, trigger: submitTrigger } = JSON.parse(paramsJson) as { - ref: string; - trigger: FormSubmissionTrigger; - }; - const element = document.querySelector(`[data-pilo-ref="${submitterRef}"]`); - if (!(element instanceof HTMLElement)) { - throw new Error(`Element with ref ${submitterRef} not found in DOM`); - } - if (!canSubmitForm(element, submitTrigger)) return null; - - const form = getSubmissionForm(element); - if (!form) return null; - - const fields = Array.from(form.elements) - .filter( - (field): field is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement => - field instanceof HTMLInputElement || - field instanceof HTMLTextAreaElement || - field instanceof HTMLSelectElement, - ) - .filter((field) => !field.disabled) - .map((field) => ({ - ref: field.getAttribute("data-pilo-ref"), - name: field.name || null, - tagName: field.tagName.toLowerCase(), - inputType: field instanceof HTMLInputElement ? field.type.toLowerCase() : null, - autocomplete: "autocomplete" in field ? field.autocomplete || null : null, - })); - - return { - submitterRef, - formId: form.id || null, - actionUrl: form.action || null, - method: form.method?.toLowerCase() || null, - fields, - }; - - function getSubmissionForm(node: HTMLElement): HTMLFormElement | null { - if ( - node instanceof HTMLButtonElement || - node instanceof HTMLInputElement || - node instanceof HTMLTextAreaElement || - node instanceof HTMLSelectElement - ) { - return node.form; + try { + const tab = await this.getActiveTab(); + await this.ensureContentScript(); + + const [{ result }] = await browser.scripting.executeScript({ + target: { tabId: tab.id! }, + func: (paramsJson: string): MetadataScriptResult => { + const { ref: submitterRef, trigger: submitTrigger } = JSON.parse(paramsJson) as { + ref: string; + trigger: FormSubmissionTrigger; + }; + const element = document.querySelector(`[data-pilo-ref="${submitterRef}"]`); + if (!(element instanceof HTMLElement)) { + return { + success: false, + error: `Element with ref ${submitterRef} not found in DOM`, + errorType: "invalid-ref", + }; + } + if (!canSubmitForm(element, submitTrigger)) return { success: true, data: null }; + + const form = getSubmissionForm(element); + if (!form) return { success: true, data: null }; + + const fields = Array.from(form.elements) + .filter( + (field): field is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement => + field instanceof HTMLInputElement || + field instanceof HTMLTextAreaElement || + field instanceof HTMLSelectElement, + ) + .filter((field) => !field.disabled) + .map((field) => ({ + ref: field.getAttribute("data-pilo-ref"), + name: field.name || null, + tagName: field.tagName.toLowerCase(), + inputType: field instanceof HTMLInputElement ? field.type.toLowerCase() : null, + autocomplete: "autocomplete" in field ? field.autocomplete || null : null, + })); + + return { + success: true, + data: { + submitterRef, + formId: form.id || null, + actionUrl: form.action || null, + method: form.method?.toLowerCase() || null, + fields, + }, + }; + + function getSubmissionForm(node: HTMLElement): HTMLFormElement | null { + if ( + node instanceof HTMLButtonElement || + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement || + node instanceof HTMLSelectElement + ) { + return node.form; + } + return node.closest("form"); } - return node.closest("form"); - } - function canSubmitForm(node: HTMLElement, submitTrigger: FormSubmissionTrigger): boolean { - if (submitTrigger === "click") { - if (node instanceof HTMLButtonElement) return node.type === "submit"; - if (node instanceof HTMLInputElement) { - return node.type === "submit" || node.type === "image"; + function canSubmitForm(node: HTMLElement, submitTrigger: FormSubmissionTrigger): boolean { + if (submitTrigger === "click") { + if (node instanceof HTMLButtonElement) return node.type === "submit"; + if (node instanceof HTMLInputElement) { + return node.type === "submit" || node.type === "image"; + } + return false; } - return false; + + if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) + return false; + if (!(node instanceof HTMLInputElement)) return false; + return ![ + "button", + "checkbox", + "color", + "file", + "hidden", + "radio", + "range", + "reset", + "submit", + ].includes(node.type); } + }, + args: [JSON.stringify({ ref, trigger })], + }); - if (node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement) - return false; - if (!(node instanceof HTMLInputElement)) return false; - return ![ - "button", - "checkbox", - "color", - "file", - "hidden", - "radio", - "range", - "reset", - "submit", - ].includes(node.type); - } - }, - args: [JSON.stringify({ ref, trigger })], - }); + return this.unwrapMetadataResult( + result as MetadataScriptResult | undefined, + ref, + "getFormSubmissionContext", + ); + } catch (error) { + if (error instanceof InvalidRefException || error instanceof BrowserActionException) { + throw error; + } + throw new BrowserActionException( + "getFormSubmissionContext", + `Failed to get form submission context: ${ + error instanceof Error ? error.message : String(error) + }`, + { ref, trigger, originalError: error }, + ); + } + } + + private unwrapMetadataResult( + result: MetadataScriptResult | undefined, + ref: string, + action: "getFieldMetadata" | "getFormSubmissionContext", + ): T { + if (!result) { + throw new BrowserActionException(action, `Failed to ${action}: script returned no result`, { + ref, + }); + } + + if (!result.success) { + if (result.errorType === "invalid-ref") { + throw new InvalidRefException(ref, result.error); + } + throw new BrowserActionException(action, result.error, { ref }); + } - return result as FormSubmissionContext | null; + return result.data; } async performAction(ref: string, action: PageAction, value?: string): Promise { diff --git a/packages/extension/test/ExtensionBrowser.test.ts b/packages/extension/test/ExtensionBrowser.test.ts index 9b48d7d1..455d37c0 100644 --- a/packages/extension/test/ExtensionBrowser.test.ts +++ b/packages/extension/test/ExtensionBrowser.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { ExtensionBrowser } from "../src/background/ExtensionBrowser"; import browser from "webextension-polyfill"; +import { BrowserActionException, InvalidRefException } from "pilo-core/core"; vi.mock("webextension-polyfill", () => ({ default: { @@ -94,4 +95,62 @@ describe("ExtensionBrowser", () => { expect(browser.scripting.executeScript).toHaveBeenCalled(); }); }); + + describe("metadata error handling", () => { + it("should translate missing field metadata refs into InvalidRefException", async () => { + vi.mocked(browser.scripting.executeScript).mockResolvedValue([ + { + result: { + success: false, + error: "Element with ref missing-input not found in DOM", + errorType: "invalid-ref", + }, + } as any, + ]); + + await expect(extensionBrowser.getFieldMetadata("missing-input")).rejects.toThrow( + InvalidRefException, + ); + }); + + it("should translate missing form submission refs into InvalidRefException", async () => { + vi.mocked(browser.scripting.executeScript).mockResolvedValue([ + { + result: { + success: false, + error: "Element with ref missing-submit not found in DOM", + errorType: "invalid-ref", + }, + } as any, + ]); + + await expect(extensionBrowser.getFormSubmissionContext("missing-submit")).rejects.toThrow( + InvalidRefException, + ); + }); + + it("should wrap field metadata script failures in BrowserActionException", async () => { + vi.mocked(browser.scripting.executeScript) + .mockResolvedValueOnce([{ result: true } as any]) + .mockRejectedValueOnce(new Error("Cannot access contents of url")); + + const error = await extensionBrowser.getFieldMetadata("input1").catch((err) => err); + expect(error).toBeInstanceOf(BrowserActionException); + expect(error.message).toContain( + "Failed to get field metadata: Cannot access contents of url", + ); + }); + + it("should wrap form submission script failures in BrowserActionException", async () => { + vi.mocked(browser.scripting.executeScript) + .mockResolvedValueOnce([{ result: true } as any]) + .mockRejectedValueOnce(new Error("Cannot access contents of url")); + + const error = await extensionBrowser.getFormSubmissionContext("submit1").catch((err) => err); + expect(error).toBeInstanceOf(BrowserActionException); + expect(error.message).toContain( + "Failed to get form submission context: Cannot access contents of url", + ); + }); + }); }); From 0860ede5856f5ba0a5b2a92a240c985c49a47faa Mon Sep 17 00:00:00 2001 From: sbrooke Date: Wed, 27 May 2026 13:17:03 -0400 Subject: [PATCH 19/44] chore: ignore historical gitleaks test fingerprints --- .gitleaksignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index d5c038bc..d782fc0b 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -4,3 +4,11 @@ # False positive: test value in historical commit (fixed in current code) 12323684c2a470321a34fea845a9556eb8b644d1:test/cli/provider.test.ts:generic-api-key:223 + +# False positives: OpenRouter test values in historical commits +6d7d33837971d7976864be4ab0642c2f5938997e:packages/cli/test/extensionConfig.test.ts:generic-api-key:74 +6d7d33837971d7976864be4ab0642c2f5938997e:packages/cli/test/extensionConfig.test.ts:generic-api-key:80 +6d7d33837971d7976864be4ab0642c2f5938997e:packages/cli/test/extensionConfig.test.ts:generic-api-key:106 +cbd8a7a9fb3fd1bba93b68f888a1a4246a243405:packages/cli/test/extensionConfig.test.ts:generic-api-key:74 +cbd8a7a9fb3fd1bba93b68f888a1a4246a243405:packages/cli/test/extensionConfig.test.ts:generic-api-key:80 +cbd8a7a9fb3fd1bba93b68f888a1a4246a243405:packages/cli/test/extensionConfig.test.ts:generic-api-key:106 From f48d1d8a031ed356013e976eac424731cab800cc Mon Sep 17 00:00:00 2001 From: Stafford Brooke Date: Wed, 27 May 2026 14:27:21 -0400 Subject: [PATCH 20/44] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/extension/src/background/ExtensionBrowser.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/background/ExtensionBrowser.ts b/packages/extension/src/background/ExtensionBrowser.ts index 65cdd47e..0d981bfc 100644 --- a/packages/extension/src/background/ExtensionBrowser.ts +++ b/packages/extension/src/background/ExtensionBrowser.ts @@ -565,7 +565,11 @@ export class ExtensionBrowser implements AriaBrowser { if (!result.success) { if (result.errorType === "invalid-ref") { - throw new InvalidRefException(ref, result.error); + const invalidRefError = new InvalidRefException(ref); + if (result.error) { + invalidRefError.message = `${invalidRefError.message} ${result.error}`; + } + throw invalidRefError; } throw new BrowserActionException(action, result.error, { ref }); } From 44fce5fa47108828caacb40466f98c87ec8d22ee Mon Sep 17 00:00:00 2001 From: Stafford Brooke Date: Wed, 27 May 2026 14:29:03 -0400 Subject: [PATCH 21/44] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../extension/src/background/ExtensionBrowser.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/background/ExtensionBrowser.ts b/packages/extension/src/background/ExtensionBrowser.ts index 0d981bfc..770aa1ef 100644 --- a/packages/extension/src/background/ExtensionBrowser.ts +++ b/packages/extension/src/background/ExtensionBrowser.ts @@ -558,9 +558,15 @@ export class ExtensionBrowser implements AriaBrowser { action: "getFieldMetadata" | "getFormSubmissionContext", ): T { if (!result) { - throw new BrowserActionException(action, `Failed to ${action}: script returned no result`, { - ref, - }); + const actionDescription = + action === "getFieldMetadata" ? "get field metadata" : "get form submission context"; + throw new BrowserActionException( + action, + `Failed to ${actionDescription}: script returned no result`, + { + ref, + }, + ); } if (!result.success) { From 847de275f49791fb0abe5dcd9b28031a12dd8d27 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Wed, 27 May 2026 14:34:51 -0400 Subject: [PATCH 22/44] fix(core): tighten firewall review followups --- packages/core/src/security/actionFirewall.ts | 1 - .../core/test/security/actionFirewall.test.ts | 11 ++++++++++ .../src/background/ExtensionBrowser.ts | 8 +++---- .../extension/test/ExtensionBrowser.test.ts | 22 +++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts index ccbce00b..0e64dd74 100644 --- a/packages/core/src/security/actionFirewall.ts +++ b/packages/core/src/security/actionFirewall.ts @@ -14,7 +14,6 @@ export type ActionFirewallResult = const OPERATIONAL_INPUT_TYPES = new Set([ "search", - "url", "number", "date", "datetime-local", diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts index ad4fa50e..721d928a 100644 --- a/packages/core/test/security/actionFirewall.test.ts +++ b/packages/core/test/security/actionFirewall.test.ts @@ -86,6 +86,17 @@ describe("actionFirewall", () => { expect(result.allowed).toBe(false); }); + it("blocks agent fills for URL fields without user approval", () => { + const result = assessFill({ + field: field({ inputType: "url", autocomplete: null }), + source: "agent", + }); + + expect(result.allowed).toBe(false); + if (result.allowed) throw new Error("Expected URL fill to be blocked"); + expect(result.reason).toBe(SECURITY_BLOCKED_UNAUTHORIZED_FILL); + }); + it("allows user-approved freeform fields", () => { const result = assessFill({ field: field({ label: "Message" }), diff --git a/packages/extension/src/background/ExtensionBrowser.ts b/packages/extension/src/background/ExtensionBrowser.ts index 770aa1ef..700ca7c7 100644 --- a/packages/extension/src/background/ExtensionBrowser.ts +++ b/packages/extension/src/background/ExtensionBrowser.ts @@ -316,7 +316,7 @@ export class ExtensionBrowser implements AriaBrowser { const tab = await this.getActiveTab(); await this.ensureContentScript(); - const [{ result }] = await browser.scripting.executeScript({ + const results = await browser.scripting.executeScript({ target: { tabId: tab.id! }, func: (elementRef: string): MetadataScriptResult => { const element = document.querySelector(`[data-pilo-ref="${elementRef}"]`); @@ -422,7 +422,7 @@ export class ExtensionBrowser implements AriaBrowser { }); return this.unwrapMetadataResult( - result as MetadataScriptResult | undefined, + results[0]?.result as MetadataScriptResult | undefined, ref, "getFieldMetadata", ); @@ -446,7 +446,7 @@ export class ExtensionBrowser implements AriaBrowser { const tab = await this.getActiveTab(); await this.ensureContentScript(); - const [{ result }] = await browser.scripting.executeScript({ + const results = await browser.scripting.executeScript({ target: { tabId: tab.id! }, func: (paramsJson: string): MetadataScriptResult => { const { ref: submitterRef, trigger: submitTrigger } = JSON.parse(paramsJson) as { @@ -534,7 +534,7 @@ export class ExtensionBrowser implements AriaBrowser { }); return this.unwrapMetadataResult( - result as MetadataScriptResult | undefined, + results[0]?.result as MetadataScriptResult | undefined, ref, "getFormSubmissionContext", ); diff --git a/packages/extension/test/ExtensionBrowser.test.ts b/packages/extension/test/ExtensionBrowser.test.ts index 455d37c0..92b2964d 100644 --- a/packages/extension/test/ExtensionBrowser.test.ts +++ b/packages/extension/test/ExtensionBrowser.test.ts @@ -141,6 +141,16 @@ describe("ExtensionBrowser", () => { ); }); + it("should wrap empty field metadata script results in BrowserActionException", async () => { + vi.mocked(browser.scripting.executeScript) + .mockResolvedValueOnce([{ result: true } as any]) + .mockResolvedValueOnce([]); + + const error = await extensionBrowser.getFieldMetadata("input1").catch((err) => err); + expect(error).toBeInstanceOf(BrowserActionException); + expect(error.message).toContain("Failed to get field metadata: script returned no result"); + }); + it("should wrap form submission script failures in BrowserActionException", async () => { vi.mocked(browser.scripting.executeScript) .mockResolvedValueOnce([{ result: true } as any]) @@ -152,5 +162,17 @@ describe("ExtensionBrowser", () => { "Failed to get form submission context: Cannot access contents of url", ); }); + + it("should wrap empty form submission script results in BrowserActionException", async () => { + vi.mocked(browser.scripting.executeScript) + .mockResolvedValueOnce([{ result: true } as any]) + .mockResolvedValueOnce([]); + + const error = await extensionBrowser.getFormSubmissionContext("submit1").catch((err) => err); + expect(error).toBeInstanceOf(BrowserActionException); + expect(error.message).toContain( + "Failed to get form submission context: script returned no result", + ); + }); }); }); From a777d817e0646f76ea188f36fd536d7778f307ac Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 13:28:05 -0400 Subject: [PATCH 23/44] docs: design spec for firewall bypass controls Adds the design for caller-supplied trusted_hostnames and unsafe_mode bypasses on top of the existing prompt-injection action firewall, plus a non-interactive-mode remediation event that surfaces all enable paths to the user without leaking guidance to the model. --- ...6-05-28-firewall-bypass-controls-design.md | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md diff --git a/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md b/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md new file mode 100644 index 00000000..d030c832 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md @@ -0,0 +1,337 @@ +# Firewall Bypass Controls — Design + +**Status:** Draft for review +**Date:** 2026-05-28 +**Branch:** `stafford/tab-976-harden-pilo-against-web-content-prompt-injection` +**Builds on:** `docs/superpowers/plans/2026-05-26-prompt-injection-action-firewall.md` + +## Problem + +The prompt-injection action firewall is correct but uniform: it blocks every agent-driven freeform fill and every form submission containing agent-filled freeform values unless the field was approved through `request_user_data`. Callers have no way to relax this on sites they trust, and no way to disable it for controlled environments where the protections are not needed. + +We need two caller-supplied controls: + +1. **Trusted hostnames** — a list of hostnames on which the firewall is bypassed for both fill and submission. +2. **Unsafe mode** — a global switch that disables the firewall entirely. + +Both controls must be opt-in, default off, and surfaced in documentation as data-protection opt-outs. + +## Non-goals + +- Heuristic trust (rating sites by domain reputation, autocomplete hints, etc.). +- Per-field trust granularity beyond what `request_user_data` already provides. +- Wildcard/subdomain matching, scheme matching, or port matching in the trusted-hostname list. +- Runtime warnings, banners, or per-action telemetry for bypassed actions. + +## Security model + +The bypass logic sits in front of the existing structural firewall. Order of evaluation: + +1. If `unsafeMode` is true → `{ allowed: true }`. +2. Else if the bypass conditions for trusted hostnames are met → `{ allowed: true }`. +3. Else fall through to the existing structural rules (operational classification, approved refs, etc.). + +### Trusted-hostname bypass conditions + +- **Fill:** the current page hostname must be in the trusted set. +- **Submission:** the current page hostname AND every resolved form-action hostname (the form's `action` plus any submitter `formaction` override) must be in the trusted set. + +A page on a non-http(s) URL (`about:blank`, `data:`, `file:`) has a `null` hostname and can never satisfy the bypass. + +### Documented limitations + +When either bypass is active, prompt injection in page content can drive the agent to fill and submit any field, including credentials, personal information, and conversation context, into forms hosted by the trusted site. The bypass is a deliberate opt-out of the firewall's data-protection guarantees. This is documented on every surface where the controls appear. + +## Architecture + +The action firewall is a pure policy module. The bypass adds one input — a `FirewallConfig` carrying caller state — and one set of short-circuit branches at the top of `assessFill` and `assessFormSubmission`. No new modules are required. + +``` +WebAgentOptions ──▶ WebAgent (normalize once at task start) + │ + ▼ + FirewallConfig (frozen) + │ + ▼ + WebActionContext.firewall + │ + ▼ + webActionTools fill/click/enter handlers + │ + ▼ + assessFill / assessFormSubmission + │ + ▼ + short-circuit if bypass applies, + otherwise existing structural rules +``` + +`browser.getUrl()` is queried once per action invocation to obtain the current page hostname. Form-action hostnames come from the existing `FormSubmissionContext` extended to carry the submitter's `formaction` override. + +## Components + +### `packages/core/src/security/actionFirewall.ts` — extended + +New exported types: + +```ts +export interface FirewallConfig { + trustedHostnames: ReadonlySet; + unsafeMode: boolean; +} +``` + +New exported helpers: + +```ts +export function normalizeHostname(input: string): string; +export function extractHostname(url: string | null): string | null; +export class InvalidHostnameError extends Error {} +``` + +`normalizeHostname`: + +- Lowercases input. +- Strips a single trailing `.`. +- Rejects empty/whitespace strings, strings containing `/`, `:`, `*`, or whitespace. +- Rejects anything that parses with `new URL(input)` as having a scheme. +- Accepts bare hostnames including IDN punycode (`xn--mnich-kva.de`) and bare IPv4 literals (`127.0.0.1`). +- Rejects bracketed IPv6 (`[::1]`) and bare IPv6 in v1. +- Throws `InvalidHostnameError` with a message naming the bad entry on rejection. + +`extractHostname`: + +- Returns the lowercased hostname (trailing dot stripped) for absolute http/https URLs. +- Returns `null` for `null` input, malformed URLs, or non-http(s) schemes (`about:`, `data:`, `file:`, `javascript:`, etc.). + +`assessFill` and `assessFormSubmission` gain two new fields on their input: + +```ts +pageHostname: string | null; +firewall: FirewallConfig; +``` + +Bypass logic in both functions (in order): + +1. If `firewall.unsafeMode` → `{ allowed: true }`. +2. Else compute trust: `pageHostname !== null && firewall.trustedHostnames.has(pageHostname)`. + For `assessFormSubmission`, additionally require every form-action hostname (form action + submitter override) to be non-null and in `firewall.trustedHostnames`. +3. If trusted → `{ allowed: true }`. +4. Else fall through to the existing structural classification. + +The existing structural classification logic is unchanged. + +### `packages/core/src/browser/ariaBrowser.ts` — extended + +`FormSubmissionContext` gains: + +```ts +submitterActionUrl: string | null; +``` + +resolved to an absolute URL by the Playwright introspection layer. `actionUrl` remains the form's `action`, also resolved to absolute. + +### `packages/core/src/browser/playwrightBrowser.ts` — updated + +`getFormSubmissionContext` resolves both `actionUrl` and `submitterActionUrl` against the page's base URL. A missing `action` attribute defaults to the current page URL (matches browser semantics). + +### `packages/core/src/tools/webActionTools.ts` — updated + +`WebActionContext` gains: + +```ts +firewall: FirewallConfig; +interactive: boolean; +``` + +`interactive` is set by `WebAgent` to `Boolean(options.onUserDataRequired)` and drives the remediation event described in the "User-facing remediation on block" section. + +Tool handlers for `fill`, `click`, and `enter`: + +1. Call `browser.getUrl()` once per invocation. +2. Pass the resulting page hostname (via `extractHostname`) and `firewall` into the firewall assessment alongside existing inputs. +3. When the assessment returns `allowed: false` and `interactive === false`, emit `FIREWALL_BLOCKED_NON_INTERACTIVE` with structured remediation context. +4. No other behavior changes to the model-visible result. + +### `packages/core/src/webAgent.ts` — updated + +`WebAgentOptions` gains: + +```ts +trustedHostnames?: readonly string[]; +unsafeMode?: boolean; +``` + +At task start, WebAgent constructs a frozen `FirewallConfig`: + +- Each entry in `trustedHostnames` is passed through `normalizeHostname`. Validation errors propagate to the caller before the agent runs. +- `unsafeMode` defaults to `false`. + +`FirewallConfig` is built once per task, threaded into `createWebActionTools`. It is not recomputed per iteration. + +### `packages/core/src/config/defaults.ts` — extended + +Two new fields in the `action` category: + +| Key | Type | Default | Description (short form) | +|---|---|---|---| +| `trusted_hostnames` | `string[]` | `[]` | Hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. | +| `unsafe_mode` | `boolean` | `false` | Disables the action firewall entirely. WARNING: web page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. | + +The field parser for `trusted_hostnames` applies `normalizeHostname` to each entry. A bad entry surfaces at config load (during `pilo config set`, `pilo config show`, or `pilo run` startup), naming the invalid value. + +### `packages/core/src/config/commander.ts` — extended + +- `--trusted-hostname ` — repeatable, collected into an array. +- `--unsafe` — boolean flag. + +Both options include the warning wording from the config descriptions. + +### `packages/core/src/config/env.ts` — extended (dev mode only) + +- `PILO_TRUSTED_HOSTNAMES` — comma-separated list. +- `PILO_UNSAFE_MODE` — `true`/`false`. + +Production mode ignores env vars (existing invariant). + +### `packages/cli/src/commands/run.ts` — updated + +Reads the merged config, builds the `WebAgentOptions` with `trustedHostnames` and `unsafeMode` from config, and passes them into `WebAgent`. No special CLI UI for bypass state. + +## User-facing remediation on block (non-interactive mode only) + +When the firewall blocks an action and the agent is **not** in interactive mode (no `onUserDataRequired` callback), the user needs to know how to enable the blocked workflow. Pilo emits a structured remediation message to user-facing channels listing every available path forward, parameterized by the blocked action's context. + +### Why interactive mode is the trigger + +In interactive mode the agent already has a path forward: `request_user_data` escalates the missing approval to the user per field. The block is recoverable in-loop. No extra user-facing messaging is needed because the standard interactive flow handles it. + +In non-interactive mode the block is terminal for that action. The user has no in-loop recourse, so we surface the configuration paths they can take to allow the action on a future run. + +### What is shown + +A `FIREWALL_BLOCKED_NON_INTERACTIVE` event is emitted on the `WebAgentEventEmitter` with structured context: + +```ts +interface FirewallBlockedNonInteractiveEventData { + reason: string; // policy reason (no field values) + kind: "freeform-fill" | "form-submission"; + pageHostname: string | null; + formActionHostnames: string[]; // empty for fills + remediations: FirewallRemediation[]; +} + +type FirewallRemediation = + | { kind: "enable-interactive-mode"; description: string } + | { kind: "add-trusted-hostnames"; hostnames: string[]; description: string } + | { kind: "enable-unsafe-mode"; description: string }; +``` + +The CLI subscribes to this event and prints a human-readable footer after the model's tool-result line. SDK callers and pilo-server can subscribe to surface the structured remediation to their end users. + +The three remediations are always included, in this order: + +1. **`add-trusted-hostnames`** — lists the page hostname and (for submissions) every form-action hostname the user would need to add. Includes the literal command `pilo config set trusted_hostnames [...]` and the SDK-equivalent option name. +2. **`enable-interactive-mode`** — instructs the caller to provide a `UserDataCallback` (`onUserDataRequired`) so the agent can request explicit user approval per field via `request_user_data`. +3. **`enable-unsafe-mode`** — disables the firewall entirely, with the documented data-protection warning. + +The remediations omit any reference to the attempted field value, consistent with the existing "no field values in errors" invariant. + +### Model isolation + +The structured remediation context is emitted to user-facing channels only. It is **not** included in the `ActionResult.error` string that goes back to the model as a tool result. The model-visible string remains the existing policy reason (`SECURITY_BLOCKED_UNAUTHORIZED_FILL` / `SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT`). This prevents prompt-injected page content from coaxing the model to suggest that the user enable `unsafe_mode` or add the attacker's hostname to `trusted_hostnames`. + +### Implementation surfaces + +- **`packages/core/src/events.ts`** — adds `FIREWALL_BLOCKED_NON_INTERACTIVE` to `WebAgentEventType` and the corresponding event data type. +- **`packages/core/src/tools/webActionTools.ts`** — when a firewall assessment returns `allowed: false`: + - Resolves the page hostname (already computed for the assessment). + - For submissions, collects every form-action hostname. + - Reads `context.interactive: boolean` (new field on `WebActionContext`, set by `WebAgent` based on the presence of `onUserDataRequired`). + - If `interactive === false`, emits `FIREWALL_BLOCKED_NON_INTERACTIVE` with the structured remediation list. + - The tool's `ActionResult.error` continues to carry only the model-visible policy reason. +- **`packages/core/src/webAgent.ts`** — populates `WebActionContext.interactive` from `Boolean(options.onUserDataRequired)`. +- **`packages/cli/src/commands/run.ts`** — listens for `FIREWALL_BLOCKED_NON_INTERACTIVE` and prints a remediation footer formatted for the terminal. The footer is distinct from the model's tool-output line and marked clearly as a Pilo-side hint. + +### Tests + +- Non-interactive mode + firewall block → `FIREWALL_BLOCKED_NON_INTERACTIVE` event emitted with both blocked hostnames and all three remediations populated. +- Interactive mode + firewall block → no `FIREWALL_BLOCKED_NON_INTERACTIVE` event. +- Model-visible `ActionResult.error` does **not** contain `unsafe_mode`, `trusted_hostnames`, or the blocked hostnames in either mode. +- CLI integration test: a non-interactive run that triggers a block prints a remediation footer naming the host that would need to be added. + +## Documentation surfaces + +The following surfaces include the explicit warning that bypassing the firewall removes data-protection guarantees. Wording is consistent across surfaces. + +- Config descriptions for `trusted_hostnames` and `unsafe_mode` (printed by `pilo config list` / `pilo config show`). +- CLI help text for `--trusted-hostname` and `--unsafe`. +- TSDoc on the new `WebAgentOptions` fields, including `@warning` blocks so warnings surface in IDE tooltips. +- A new "Security model" subsection in the root README documenting the firewall and naming both bypasses as deliberate data-protection opt-outs. This section also describes the non-interactive-mode remediation footer so users running the CLI know what to expect when a block surfaces. + +Documentation is the compensating control for the silent-observability decision on **bypassed** actions: there is no runtime banner or per-action telemetry when a bypass is in effect, so it must be unambiguous in docs that turning these on weakens protections. Blocked actions in non-interactive mode are the inverse case — they are deliberately verbose at the user-facing layer so the user can choose a path forward. + +## Error handling + +- **Invalid hostname at config load** — `normalizeHostname` throws `InvalidHostnameError` with a message naming the bad entry. CLI surfaces the message and exits non-zero before the agent runs. +- **`browser.getUrl()` failure** — treated as `pageHostname = null`. Bypass cannot apply; existing structural rules run as today. +- **Form action URL resolution failure** — treated as `null` hostname for that action. Bypass cannot apply for that submission; falls through to structural rules. +- **Both `unsafeMode` and `trustedHostnames` set** — `unsafeMode` short-circuits first; `trustedHostnames` is moot. + +## Backwards compatibility + +- Both new fields default to safe values (`false`, `[]`). +- Existing config files and existing callers require no changes. +- The bypass branches are additive; the structural classification is untouched when neither bypass applies. + +## Testing + +### Pure firewall tests — `packages/core/test/security/actionFirewall.test.ts` + +- `normalizeHostname`: accepts bare hostnames; lowercases; strips trailing dot; rejects schemes, paths, wildcards, whitespace, empty. +- `extractHostname`: returns hostname for http(s); returns `null` for `about:blank`, `data:`, `file:`, malformed input, `null` input. +- `assessFill` with `unsafeMode=true` → allowed for any field regardless of source. +- `assessFill` with trusted page hostname → allowed for freeform field that would otherwise block. +- `assessFill` with untrusted page hostname → falls through to existing rules. +- `assessFill` with `pageHostname=null` → never bypasses (even if `""` were in trusted set). +- `assessFormSubmission` with `unsafeMode=true` → allowed for any form. +- `assessFormSubmission` with trusted page + all form-action hostnames trusted → allowed. +- `assessFormSubmission` with trusted page + one form-action hostname untrusted → falls through and blocks. +- `assessFormSubmission` with trusted page + `null` form-action hostname → falls through. +- `assessFormSubmission` with untrusted page + trusted form-action → falls through. +- `assessFormSubmission` checks both `actionUrl` and `submitterActionUrl`. + +### Tool-level tests — `packages/core/test/tools/webActionTools.test.ts` + +- Fill of textarea on trusted page → allowed without `request_user_data`. +- Fill of textarea on untrusted page → blocked as today. +- Click submit on trusted page with form action on same trusted host → allowed. +- Click submit on trusted page with form action on untrusted host → blocked. +- `unsafeMode=true` → fill/submit allowed on any page including freeform fields and untrusted form actions. +- Blocked results never include the attempted field value (existing invariant preserved). + +### Config tests — under `packages/core/test/config/` + +- `pilo config set trusted_hostnames a.com b.com` persists normalized list. +- Invalid entry throws at parse time, naming the bad entry. +- CLI `--trusted-hostname example.com --trusted-hostname app.example.com` builds an array. +- CLI `--unsafe` flips the boolean. +- Env (dev mode): `PILO_TRUSTED_HOSTNAMES="a.com,b.com"` parses to array; `PILO_UNSAFE_MODE=true` flips boolean. +- Production mode ignores env (existing invariant). + +### WebAgent integration tests — `packages/core/test/webAgent.test.ts` + +- Options-supplied `trustedHostnames` plumbs through to tool context and gates as expected on a fake page. +- Options-supplied `unsafeMode=true` bypasses both gates end-to-end. +- Regression: existing prompt-injection test still blocks on a non-trusted page with both bypasses off. + +## Out of scope + +- Wildcard / subdomain matching. +- Per-field trust override beyond `request_user_data`. +- Runtime warning UI when a bypass is active (documentation is the compensating control). +- Reputation-based or heuristic trust. + +## Open questions + +None at design time. Implementation may surface platform-specific edge cases in URL resolution under Playwright; those will be addressed during the build-out. From 4d37dbb057ebf1f4813a3a08c2487c9d0e1b94bc Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 13:37:38 -0400 Subject: [PATCH 24/44] docs: implementation plan for firewall bypass controls Task-by-task plan for the 2026-05-28 firewall-bypass-controls spec: hostname helpers, FirewallConfig + bypass branches, FormSubmissionContext submitterActionUrl, FIREWALL_BLOCKED_NON_INTERACTIVE event, web-tools plumbing, WebAgent option additions, config/CLI/env wiring, CLI remediation footer, docs, final validation. --- .../2026-05-28-firewall-bypass-controls.md | 2036 +++++++++++++++++ 1 file changed, 2036 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md diff --git a/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md b/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md new file mode 100644 index 00000000..cfa891a3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md @@ -0,0 +1,2036 @@ +# Firewall Bypass Controls Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two caller-supplied controls on top of the existing prompt-injection action firewall — a `trusted_hostnames` list that bypasses both fill and submit gates when the page and form-action hostnames all match, and an `unsafe_mode` global firewall disable. Also surface remediation guidance to the user when a block fires in non-interactive mode. + +**Architecture:** Extend the pure firewall policy with a `FirewallConfig` input and short-circuit branches in front of the existing structural rules. Surface the new controls through `WebAgentOptions`, `PiloConfig`, CLI flags, and (dev-mode) env vars. On block in non-interactive mode, emit a structured `FIREWALL_BLOCKED_NON_INTERACTIVE` event for user-facing channels only; the model-visible tool-result error stays minimal. + +**Tech Stack:** TypeScript, Vitest, Playwright, AI SDK tools, Commander, eventemitter3, existing `AriaBrowser` / `webActionTools` / `ConfigManager` modules. + +**Builds on:** `docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md`. + +--- + +## File Structure + +| Action | Path | Responsibility | +|---|---|---| +| Modify | `packages/core/src/security/actionFirewall.ts` | `FirewallConfig` type, `normalizeHostname`, `extractHostname`, `InvalidHostnameError`, bypass branches in `assessFill`/`assessFormSubmission` | +| Modify | `packages/core/src/browser/ariaBrowser.ts` | Add `submitterActionUrl` to `FormSubmissionContext` | +| Modify | `packages/core/src/browser/playwrightBrowser.ts` | Resolve and return `submitterActionUrl` | +| Modify | `packages/core/src/events.ts` | Add `FIREWALL_BLOCKED_NON_INTERACTIVE` event type + data type | +| Modify | `packages/core/src/tools/webActionTools.ts` | Extend `WebActionContext` with `firewall` and `interactive`; query page hostname; pass to firewall; emit non-interactive event on block | +| Modify | `packages/core/src/webAgent.ts` | Add `trustedHostnames` / `unsafeMode` options; build frozen `FirewallConfig`; thread `interactive` into tool context | +| Modify | `packages/core/src/config/defaults.ts` | New `trusted_hostnames` (string[]) and `unsafe_mode` (boolean) fields with warning descriptions | +| Modify | `packages/core/src/config/commander.ts` | (No code change expected — `addConfigOptions` already handles `string[]` and `boolean` types automatically) | +| Modify | `packages/core/src/config/env.ts` | (No code change expected — generic env coercion handles both new fields) | +| Modify | `packages/cli/src/commands/run.ts` | Pass `trustedHostnames` / `unsafeMode` from merged config into `WebAgent`; subscribe to `FIREWALL_BLOCKED_NON_INTERACTIVE` and print remediation footer | +| Modify | `packages/core/src/index.ts` and `packages/core/src/core.ts` | Re-export `InvalidHostnameError` if it needs to be caught by callers | +| Create | `packages/core/test/security/actionFirewall.test.ts` | Add pure tests for normalization + bypass logic (file already exists per prior plan; new test cases appended) | +| Modify | `packages/core/test/tools/webActionTools.test.ts` | Tool-level tests for bypass and remediation event | +| Modify | `packages/core/test/playwrightBrowser.test.ts` | Test that `submitterActionUrl` is resolved and returned | +| Modify | `packages/core/test/webAgent.test.ts` | Integration tests for option plumbing and end-to-end bypass behavior | +| Modify | `README.md` (root) | Add "Security model" subsection | + +--- + +## Task 1: Hostname normalization and extraction helpers + +**Files:** +- Modify: `packages/core/src/security/actionFirewall.ts` +- Test: `packages/core/test/security/actionFirewall.test.ts` + +- [ ] **Step 1: Write failing tests for `normalizeHostname` and `extractHostname`** + +Append to `packages/core/test/security/actionFirewall.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + normalizeHostname, + extractHostname, + InvalidHostnameError, +} from "../../src/security/actionFirewall.js"; + +describe("normalizeHostname", () => { + it("lowercases input", () => { + expect(normalizeHostname("Example.COM")).toBe("example.com"); + }); + + it("strips a single trailing dot", () => { + expect(normalizeHostname("example.com.")).toBe("example.com"); + }); + + it("accepts bare hostnames", () => { + expect(normalizeHostname("app.example.com")).toBe("app.example.com"); + }); + + it("accepts IDN punycode", () => { + expect(normalizeHostname("xn--mnich-kva.de")).toBe("xn--mnich-kva.de"); + }); + + it("accepts bare IPv4 literals", () => { + expect(normalizeHostname("127.0.0.1")).toBe("127.0.0.1"); + }); + + it("rejects empty string", () => { + expect(() => normalizeHostname("")).toThrow(InvalidHostnameError); + }); + + it("rejects whitespace-only", () => { + expect(() => normalizeHostname(" ")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with whitespace", () => { + expect(() => normalizeHostname("ex ample.com")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with slashes", () => { + expect(() => normalizeHostname("example.com/path")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with colons", () => { + expect(() => normalizeHostname("example.com:8080")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with wildcards", () => { + expect(() => normalizeHostname("*.example.com")).toThrow(InvalidHostnameError); + }); + + it("rejects URL inputs with scheme", () => { + expect(() => normalizeHostname("https://example.com")).toThrow(InvalidHostnameError); + }); + + it("rejects bracketed IPv6 in v1", () => { + expect(() => normalizeHostname("[::1]")).toThrow(InvalidHostnameError); + }); + + it("error message names the bad entry", () => { + try { + normalizeHostname("bad value"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidHostnameError); + expect((e as Error).message).toContain("bad value"); + } + }); +}); + +describe("extractHostname", () => { + it("returns lowercase hostname for https URLs", () => { + expect(extractHostname("https://Example.COM/path?q=1")).toBe("example.com"); + }); + + it("returns lowercase hostname for http URLs", () => { + expect(extractHostname("http://app.example.com")).toBe("app.example.com"); + }); + + it("strips trailing dot", () => { + expect(extractHostname("https://example.com./")).toBe("example.com"); + }); + + it("returns null for null input", () => { + expect(extractHostname(null)).toBeNull(); + }); + + it("returns null for about:blank", () => { + expect(extractHostname("about:blank")).toBeNull(); + }); + + it("returns null for data: URLs", () => { + expect(extractHostname("data:text/html,

x

")).toBeNull(); + }); + + it("returns null for file: URLs", () => { + expect(extractHostname("file:///tmp/foo.html")).toBeNull(); + }); + + it("returns null for javascript: URLs", () => { + expect(extractHostname("javascript:alert(1)")).toBeNull(); + }); + + it("returns null for malformed URLs", () => { + expect(extractHostname("not a url")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(extractHostname("")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "normalizeHostname"` +Expected: FAIL — `normalizeHostname`, `extractHostname`, `InvalidHostnameError` not exported. + +- [ ] **Step 3: Implement the helpers in `actionFirewall.ts`** + +Append to `packages/core/src/security/actionFirewall.ts`: + +```ts +export class InvalidHostnameError extends Error { + constructor(input: string, reason: string) { + super(`Invalid hostname "${input}": ${reason}`); + this.name = "InvalidHostnameError"; + } +} + +const HOSTNAME_DISALLOWED_CHARS = /[\s/:*]/; + +export function normalizeHostname(input: string): string { + if (typeof input !== "string") { + throw new InvalidHostnameError(String(input), "not a string"); + } + const trimmed = input.trim(); + if (trimmed.length === 0) { + throw new InvalidHostnameError(input, "empty"); + } + if (HOSTNAME_DISALLOWED_CHARS.test(trimmed)) { + throw new InvalidHostnameError(input, "contains whitespace, '/', ':', or '*'"); + } + if (trimmed.startsWith("[") || trimmed.endsWith("]")) { + throw new InvalidHostnameError(input, "bracketed IPv6 is not supported"); + } + let withoutTrailingDot = trimmed; + if (withoutTrailingDot.endsWith(".")) { + withoutTrailingDot = withoutTrailingDot.slice(0, -1); + } + if (withoutTrailingDot.length === 0) { + throw new InvalidHostnameError(input, "empty after trimming trailing dot"); + } + return withoutTrailingDot.toLowerCase(); +} + +export function extractHostname(url: string | null): string | null { + if (url === null || url === undefined) return null; + if (typeof url !== "string" || url.length === 0) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + let host = parsed.hostname.toLowerCase(); + if (host.endsWith(".")) host = host.slice(0, -1); + if (host.length === 0) return null; + return host; +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "normalizeHostname"` +Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "extractHostname"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/security/actionFirewall.ts packages/core/test/security/actionFirewall.test.ts +git commit -m "feat(core): add hostname normalization and extraction helpers" +``` + +--- + +## Task 2: FirewallConfig type and bypass branches + +**Files:** +- Modify: `packages/core/src/security/actionFirewall.ts` +- Modify: `packages/core/test/security/actionFirewall.test.ts` +- Modify (consumer): `packages/core/src/tools/webActionTools.ts` (compile-fix only) + +- [ ] **Step 1: Write failing tests for bypass behavior** + +Append to `packages/core/test/security/actionFirewall.test.ts`: + +```ts +import { + assessFill, + assessFormSubmission, + type FirewallConfig, +} from "../../src/security/actionFirewall.js"; +import type { + FieldMetadata, + FormSubmissionContext, +} from "../../src/browser/ariaBrowser.js"; + +const freeformField: FieldMetadata = { + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, +}; + +const emptyFirewall: FirewallConfig = { + trustedHostnames: new Set(), + unsafeMode: false, +}; + +function withTrusted(hosts: string[]): FirewallConfig { + return { trustedHostnames: new Set(hosts), unsafeMode: false }; +} + +const unsafeFirewall: FirewallConfig = { + trustedHostnames: new Set(), + unsafeMode: true, +}; + +describe("assessFill bypass branches", () => { + it("unsafeMode allows any field regardless of source", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: null, + firewall: unsafeFirewall, + }); + expect(result.allowed).toBe(true); + }); + + it("trusted page hostname allows freeform fill", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(true); + }); + + it("untrusted page hostname falls through to existing rules and blocks freeform", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: "attacker.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("pageHostname=null never bypasses", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: null, + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); +}); + +const baseForm: FormSubmissionContext = { + submitterRef: "submit-1", + formId: null, + actionUrl: "https://example.com/submit", + submitterActionUrl: null, + method: "post", + fields: [ + { + ref: "ref-1", + name: "comment", + tagName: "textarea", + inputType: null, + autocomplete: null, + }, + ], +}; + +describe("assessFormSubmission bypass branches", () => { + it("unsafeMode allows any form", () => { + const result = assessFormSubmission({ + form: baseForm, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "attacker.com", + firewall: unsafeFirewall, + }); + expect(result.allowed).toBe(true); + }); + + it("trusted page + trusted form action allows submission", () => { + const result = assessFormSubmission({ + form: baseForm, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(true); + }); + + it("trusted page + untrusted form action falls through and blocks", () => { + const result = assessFormSubmission({ + form: { ...baseForm, actionUrl: "https://attacker.com/exfil" }, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("trusted page + null form action hostname falls through", () => { + const result = assessFormSubmission({ + form: { ...baseForm, actionUrl: "about:blank" }, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("untrusted page + trusted form action falls through", () => { + const result = assessFormSubmission({ + form: baseForm, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "attacker.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("checks submitter action URL when present", () => { + const result = assessFormSubmission({ + form: { + ...baseForm, + actionUrl: "https://example.com/normal", + submitterActionUrl: "https://attacker.com/override", + }, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("falls through (no bypass) when nothing is agent-filled but submitter is untrusted", () => { + const result = assessFormSubmission({ + form: { ...baseForm, actionUrl: "https://attacker.com/exfil" }, + approvedRefs: new Set(), + agentFilledRefs: new Set(), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(true); // existing rule: no agent-filled => allowed + }); +}); +``` + +Note: this references `submitterActionUrl` on `FormSubmissionContext`. That field is added in Task 3. Compilation will fail until Task 3 lands. That's expected; complete tasks in order. + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "bypass"` +Expected: FAIL — `FirewallConfig` and the new signature arguments do not exist. + +- [ ] **Step 3: Extend the firewall in `actionFirewall.ts`** + +Replace existing exported types and signatures with the bypass-aware versions. Full file content: + +```ts +import type { FieldMetadata, FormSubmissionContext } from "../browser/ariaBrowser.js"; + +export const SECURITY_BLOCKED_UNAUTHORIZED_FILL = + "Security policy blocked filling a submittable form field without user approval"; + +export const SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT = + "Security policy blocked submitting a form containing unauthorized agent-filled data"; + +export type FillSource = "agent" | "user-approved"; + +export type ActionFirewallResult = + | { allowed: true; operational?: boolean } + | { allowed: false; reason: string; isRecoverable: true }; + +export interface FirewallConfig { + trustedHostnames: ReadonlySet; + unsafeMode: boolean; +} + +export class InvalidHostnameError extends Error { + constructor(input: string, reason: string) { + super(`Invalid hostname "${input}": ${reason}`); + this.name = "InvalidHostnameError"; + } +} + +const HOSTNAME_DISALLOWED_CHARS = /[\s/:*]/; + +export function normalizeHostname(input: string): string { + if (typeof input !== "string") { + throw new InvalidHostnameError(String(input), "not a string"); + } + const trimmed = input.trim(); + if (trimmed.length === 0) { + throw new InvalidHostnameError(input, "empty"); + } + if (HOSTNAME_DISALLOWED_CHARS.test(trimmed)) { + throw new InvalidHostnameError(input, "contains whitespace, '/', ':', or '*'"); + } + if (trimmed.startsWith("[") || trimmed.endsWith("]")) { + throw new InvalidHostnameError(input, "bracketed IPv6 is not supported"); + } + let withoutTrailingDot = trimmed; + if (withoutTrailingDot.endsWith(".")) { + withoutTrailingDot = withoutTrailingDot.slice(0, -1); + } + if (withoutTrailingDot.length === 0) { + throw new InvalidHostnameError(input, "empty after trimming trailing dot"); + } + return withoutTrailingDot.toLowerCase(); +} + +export function extractHostname(url: string | null): string | null { + if (url === null || url === undefined) return null; + if (typeof url !== "string" || url.length === 0) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + let host = parsed.hostname.toLowerCase(); + if (host.endsWith(".")) host = host.slice(0, -1); + if (host.length === 0) return null; + return host; +} + +const OPERATIONAL_INPUT_TYPES = new Set([ + "search", + "number", + "date", + "datetime-local", + "month", + "time", + "week", + "color", + "range", +]); + +const OPERATIONAL_ROLES = new Set(["searchbox", "combobox", "spinbutton", "slider"]); + +const SENSITIVE_AUTOCOMPLETE_TOKENS = new Set([ + "name", + "honorific-prefix", + "given-name", + "additional-name", + "family-name", + "honorific-suffix", + "nickname", + "email", + "username", + "new-password", + "current-password", + "one-time-code", + "organization", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "address-level1", + "address-level2", + "address-level3", + "address-level4", + "country", + "country-name", + "postal-code", + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number", + "cc-exp", + "cc-exp-month", + "cc-exp-year", + "cc-csc", + "cc-type", + "transaction-currency", + "transaction-amount", + "language", + "bday", + "bday-day", + "bday-month", + "bday-year", + "sex", + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", + "tel-extension", + "impp", + "url", + "photo", +]); + +export function assessFill(input: { + field: FieldMetadata; + source: FillSource; + pageHostname: string | null; + firewall: FirewallConfig; +}): ActionFirewallResult { + if (input.firewall.unsafeMode) { + return { allowed: true }; + } + if ( + input.pageHostname !== null && + input.firewall.trustedHostnames.has(input.pageHostname) + ) { + return { allowed: true }; + } + + if (input.source === "user-approved") { + return { allowed: true }; + } + + if (isOperationalField(input.field)) { + return { allowed: true, operational: true }; + } + + return { + allowed: false, + reason: SECURITY_BLOCKED_UNAUTHORIZED_FILL, + isRecoverable: true, + }; +} + +export function assessFormSubmission(input: { + form: FormSubmissionContext; + approvedRefs: ReadonlySet; + agentFilledRefs: ReadonlySet; + operationalRefs: ReadonlySet; + pageHostname: string | null; + firewall: FirewallConfig; +}): ActionFirewallResult { + if (input.firewall.unsafeMode) { + return { allowed: true }; + } + + if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { + const actionUrls = [input.form.actionUrl, input.form.submitterActionUrl]; + const allFormActionsTrusted = actionUrls.every((url) => { + const host = extractHostname(url); + return host !== null && input.firewall.trustedHostnames.has(host); + }); + if (allFormActionsTrusted) { + return { allowed: true }; + } + } + + for (const field of input.form.fields) { + if (!field.ref || !input.agentFilledRefs.has(field.ref)) continue; + if (input.approvedRefs.has(field.ref) || input.operationalRefs.has(field.ref)) continue; + + return { + allowed: false, + reason: SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, + isRecoverable: true, + }; + } + + return { allowed: true }; +} + +function isOperationalField(field: FieldMetadata): boolean { + const inputType = field.inputType?.toLowerCase() ?? null; + const role = field.role?.toLowerCase() ?? null; + + if (hasSensitiveAutocomplete(field.autocomplete)) return false; + if (field.tagName.toLowerCase() === "textarea" || field.isContentEditable) return false; + if (inputType && OPERATIONAL_INPUT_TYPES.has(inputType)) return true; + if (role && OPERATIONAL_ROLES.has(role)) return true; + return false; +} + +function hasSensitiveAutocomplete(autocomplete: string | null): boolean { + if (!autocomplete) return false; + const tokens = autocomplete.toLowerCase().split(/\s+/); + return tokens.some((token) => SENSITIVE_AUTOCOMPLETE_TOKENS.has(token)); +} +``` + +Note on the form-action check: when `submitterActionUrl` is `null`, `extractHostname(null)` returns `null` and the check fails — meaning the bypass does not apply. This is intentionally strict; if you want to allow the bypass when no submitter override is present, change the iteration to skip `null` entries. **Correct behavior:** if the only action URL is the form's `actionUrl`, bypass should still work. Update the iteration: + +Replace this snippet inside `assessFormSubmission` with the correct semantics: + +```ts +if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { + const formActionHost = extractHostname(input.form.actionUrl); + const submitterActionHost = extractHostname(input.form.submitterActionUrl); + + const formActionTrusted = + formActionHost !== null && input.firewall.trustedHostnames.has(formActionHost); + + // submitterActionUrl is optional. If null, treat as "no override" (trusted). + // If present, it must resolve to a trusted hostname. + const submitterTrusted = + input.form.submitterActionUrl === null + ? true + : submitterActionHost !== null && input.firewall.trustedHostnames.has(submitterActionHost); + + if (formActionTrusted && submitterTrusted) { + return { allowed: true }; + } +} +``` + +Use that second snippet, not the first. + +- [ ] **Step 4: Update the existing `assessFill` and `assessFormSubmission` call sites to pass the new fields** + +The existing `webActionTools.ts` and any existing tests call these without `pageHostname` and `firewall`. Compile-fix only here — actual plumbing happens in Tasks 5 and 6. Locate every caller via: + +Run: `grep -rn "assessFill\|assessFormSubmission" packages/core/src packages/core/test` + +For each caller in `packages/core/src/tools/webActionTools.ts`, add temporary fields so the build compiles: + +In `webActionTools.ts:232-235`, replace: + +```ts +const assessment = assessFill({ + field: metadata, + source: userApproved ? "user-approved" : "agent", +}); +``` + +with: + +```ts +const assessment = assessFill({ + field: metadata, + source: userApproved ? "user-approved" : "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, +}); +``` + +In `webActionTools.ts:90-95` (inside `assessFormSubmissionForAction`), replace: + +```ts +const assessment = assessFormSubmission({ + form, + approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, + agentFilledRefs: context.agentFilledRefs, + operationalRefs: context.operationalRefs, +}); +``` + +with: + +```ts +const assessment = assessFormSubmission({ + form, + approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, + agentFilledRefs: context.agentFilledRefs, + operationalRefs: context.operationalRefs, + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, +}); +``` + +These temporary literals preserve existing behavior (no bypass) until Task 5 replaces them with the real plumbed-through values. + +Update any existing test callers in `packages/core/test/security/actionFirewall.test.ts` from the original spec (Task 3 in the prior plan added them) to pass the same literals. Use `grep -n "assessFill\|assessFormSubmission" packages/core/test/security/actionFirewall.test.ts` to find them. + +- [ ] **Step 5: Run all firewall-related tests to verify pass** + +Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts` +Expected: PASS. + +Run: `pnpm --filter pilo-core run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/security/actionFirewall.ts packages/core/src/tools/webActionTools.ts packages/core/test/security/actionFirewall.test.ts +git commit -m "feat(core): add FirewallConfig and bypass branches to action firewall" +``` + +--- + +## Task 3: Add `submitterActionUrl` to FormSubmissionContext + +**Files:** +- Modify: `packages/core/src/browser/ariaBrowser.ts` +- Modify: `packages/core/src/browser/playwrightBrowser.ts` +- Modify: `packages/core/test/playwrightBrowser.test.ts` + +- [ ] **Step 1: Write a failing browser test for `submitterActionUrl`** + +Append to `packages/core/test/playwrightBrowser.test.ts` (locate the existing `describe` block that tests `getFormSubmissionContext` and add a sibling test inside it): + +```ts +it("returns submitterActionUrl when the submit button has a formaction attribute", async () => { + await page.setContent(` +
+ + +
+ `); + + const ctx = await browser.getFormSubmissionContext("btn", "click"); + expect(ctx).not.toBeNull(); + expect(ctx!.actionUrl).toBe("https://example.com/normal"); + expect(ctx!.submitterActionUrl).toBe("https://override.example.com/special"); +}); + +it("returns null submitterActionUrl when the submit button has no formaction", async () => { + await page.setContent(` +
+ + +
+ `); + + const ctx = await browser.getFormSubmissionContext("btn", "click"); + expect(ctx).not.toBeNull(); + expect(ctx!.submitterActionUrl).toBeNull(); +}); +``` + +Adapt setup to match the existing test file's pattern (page/browser fixtures). If the existing tests use a different page setup helper, use that helper instead of `setContent` directly. + +- [ ] **Step 2: Run test, verify it fails** + +Run: `pnpm --dir packages/core exec vitest run test/playwrightBrowser.test.ts -t "submitterActionUrl"` +Expected: FAIL — `submitterActionUrl` not on `FormSubmissionContext`. + +- [ ] **Step 3: Extend `FormSubmissionContext` interface** + +In `packages/core/src/browser/ariaBrowser.ts`, locate the `FormSubmissionContext` interface (around line 83) and add the field: + +```ts +export interface FormSubmissionContext { + submitterRef: string; + formId: string | null; + actionUrl: string | null; + submitterActionUrl: string | null; + method: string | null; + fields: FormFieldState[]; +} +``` + +- [ ] **Step 4: Compute `submitterActionUrl` in `playwrightBrowser.ts`** + +In `packages/core/src/browser/playwrightBrowser.ts`, locate `getFormSubmissionContext` (around line 901). Inside the `locator.evaluate` callback, compute the submitter's `formAction` if it's a button-like element with the attribute set: + +After the `getSubmissionForm` and `canSubmitForm` helper definitions (still inside the evaluate callback) and before the `return` statement, modify the existing return to include `submitterActionUrl`: + +```ts +const submitterActionUrl = (() => { + if (!(el instanceof HTMLButtonElement) && !(el instanceof HTMLInputElement)) return null; + // formaction attribute only meaningful on submit/image inputs and submit buttons + if (el instanceof HTMLInputElement && el.type !== "submit" && el.type !== "image") return null; + if (el instanceof HTMLButtonElement && el.type !== "submit") return null; + if (!el.hasAttribute("formaction")) return null; + // formAction property resolves to an absolute URL when attribute is set + return el.formAction || null; +})(); + +return { + submitterRef, + formId: form.id || null, + actionUrl: form.action || null, + submitterActionUrl, + method: form.method?.toLowerCase() || null, + fields, +}; +``` + +- [ ] **Step 5: Run test, verify it passes** + +Run: `pnpm --dir packages/core exec vitest run test/playwrightBrowser.test.ts -t "submitterActionUrl"` +Expected: PASS. + +Run: `pnpm --filter pilo-core run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/browser/ariaBrowser.ts packages/core/src/browser/playwrightBrowser.ts packages/core/test/playwrightBrowser.test.ts +git commit -m "feat(core): expose submitter formaction override on FormSubmissionContext" +``` + +--- + +## Task 4: Add `FIREWALL_BLOCKED_NON_INTERACTIVE` event type + +**Files:** +- Modify: `packages/core/src/events.ts` + +- [ ] **Step 1: Add the event type enum value** + +In `packages/core/src/events.ts`, locate `enum WebAgentEventType` (around line 9) and add the new value at the end of the enum, before the closing brace: + +```ts + // Firewall events + FIREWALL_BLOCKED_NON_INTERACTIVE = "firewall:blocked_non_interactive", +``` + +- [ ] **Step 2: Add the data type for the event** + +Still in `packages/core/src/events.ts`, after the existing event-data interfaces (search for the file pattern; add the new interface in the same style as e.g. `BrowserActionResultEventData`): + +```ts +export type FirewallRemediation = + | { kind: "add-trusted-hostnames"; hostnames: string[]; description: string } + | { kind: "enable-interactive-mode"; description: string } + | { kind: "enable-unsafe-mode"; description: string }; + +export interface FirewallBlockedNonInteractiveEventData extends WebAgentEventData { + reason: string; + kind: "freeform-fill" | "form-submission"; + pageHostname: string | null; + formActionHostnames: string[]; + remediations: FirewallRemediation[]; +} +``` + +- [ ] **Step 3: Add the event to the discriminated union** + +In `packages/core/src/events.ts`, locate the `WebAgentEvent` discriminated union (the long `|`-chain starting around line 370). Add a new arm at the end of the union: + +```ts + | { + type: WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE; + data: FirewallBlockedNonInteractiveEventData; + }; +``` + +If the union ends with a `;` after the last arm, place the new arm before that terminator. Match the file's existing punctuation exactly. + +- [ ] **Step 4: Typecheck** + +Run: `pnpm --filter pilo-core run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/events.ts +git commit -m "feat(core): add FIREWALL_BLOCKED_NON_INTERACTIVE event type" +``` + +--- + +## Task 5: Plumb `firewall` and `interactive` into webActionTools and emit the event + +**Files:** +- Modify: `packages/core/src/tools/webActionTools.ts` +- Modify: `packages/core/test/tools/webActionTools.test.ts` + +- [ ] **Step 1: Write failing tool-level tests** + +Append to `packages/core/test/tools/webActionTools.test.ts`. Use the same setup pattern as the existing tests in that file (look for `createWebActionTools` usage). Add tests: + +```ts +import { WebAgentEventType, WebAgentEventEmitter } from "../../src/events.js"; +import type { FirewallConfig } from "../../src/security/actionFirewall.js"; + +describe("webActionTools firewall bypass and remediation", () => { + it("trustedHostnames allows freeform fill on a trusted page", async () => { + const browser = createMockBrowser({ + getUrl: async () => "https://example.com/page", + getFieldMetadata: async () => ({ + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }), + performAction: vi.fn().mockResolvedValue(undefined), + }); + const eventEmitter = new WebAgentEventEmitter(); + const firewall: FirewallConfig = { + trustedHostnames: new Set(["example.com"]), + unsafeMode: false, + }; + + const tools = createWebActionTools({ + browser, + eventEmitter, + providerConfig: stubProviderConfig, + firewall, + interactive: false, + agentFilledRefs: new Set(), + operationalRefs: new Set(), + }); + + const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); + expect(result.success).toBe(true); + expect(browser.performAction).toHaveBeenCalled(); + }); + + it("unsafeMode allows fill of any field", async () => { + const browser = createMockBrowser({ + getUrl: async () => "https://attacker.com/", + getFieldMetadata: async () => ({ + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }), + performAction: vi.fn().mockResolvedValue(undefined), + }); + const eventEmitter = new WebAgentEventEmitter(); + const firewall: FirewallConfig = { + trustedHostnames: new Set(), + unsafeMode: true, + }; + + const tools = createWebActionTools({ + browser, + eventEmitter, + providerConfig: stubProviderConfig, + firewall, + interactive: false, + agentFilledRefs: new Set(), + operationalRefs: new Set(), + }); + + const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); + expect(result.success).toBe(true); + }); + + it("emits FIREWALL_BLOCKED_NON_INTERACTIVE on fill block when interactive=false", async () => { + const browser = createMockBrowser({ + getUrl: async () => "https://untrusted.com/", + getFieldMetadata: async () => ({ + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }), + performAction: vi.fn(), + }); + const eventEmitter = new WebAgentEventEmitter(); + const events: unknown[] = []; + eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => events.push(data)); + + const tools = createWebActionTools({ + browser, + eventEmitter, + providerConfig: stubProviderConfig, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, + interactive: false, + agentFilledRefs: new Set(), + operationalRefs: new Set(), + }); + + const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); + expect(result.success).toBe(false); + expect(browser.performAction).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + const data = events[0] as { + kind: string; + pageHostname: string | null; + remediations: Array<{ kind: string }>; + }; + expect(data.kind).toBe("freeform-fill"); + expect(data.pageHostname).toBe("untrusted.com"); + expect(data.remediations.map((r) => r.kind).sort()).toEqual( + ["add-trusted-hostnames", "enable-interactive-mode", "enable-unsafe-mode"].sort(), + ); + }); + + it("does NOT emit FIREWALL_BLOCKED_NON_INTERACTIVE when interactive=true", async () => { + const browser = createMockBrowser({ + getUrl: async () => "https://untrusted.com/", + getFieldMetadata: async () => ({ + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }), + performAction: vi.fn(), + }); + const eventEmitter = new WebAgentEventEmitter(); + const events: unknown[] = []; + eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => events.push(data)); + + const tools = createWebActionTools({ + browser, + eventEmitter, + providerConfig: stubProviderConfig, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, + interactive: true, + agentFilledRefs: new Set(), + operationalRefs: new Set(), + }); + + const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); + expect(result.success).toBe(false); + expect(events).toHaveLength(0); + }); + + it("model-visible error string does not include unsafe_mode or trusted_hostnames", async () => { + const browser = createMockBrowser({ + getUrl: async () => "https://untrusted.com/", + getFieldMetadata: async () => ({ + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }), + performAction: vi.fn(), + }); + const tools = createWebActionTools({ + browser, + eventEmitter: new WebAgentEventEmitter(), + providerConfig: stubProviderConfig, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, + interactive: false, + agentFilledRefs: new Set(), + operationalRefs: new Set(), + }); + + const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error).not.toMatch(/unsafe_mode|trusted_hostnames|untrusted\.com/); + }); +}); +``` + +If `createMockBrowser`, `stubProviderConfig`, and `stubExecOptions` aren't already defined in the test file, follow the patterns used in existing tests in that same file. If `getUrl` is not already part of the mock-browser pattern, extend the mock factory to accept and return it. + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `pnpm --dir packages/core exec vitest run test/tools/webActionTools.test.ts -t "firewall bypass"` +Expected: FAIL — `WebActionContext` does not accept `firewall` or `interactive`, event is not emitted. + +- [ ] **Step 3: Extend `WebActionContext` and wire firewall + interactive into handlers** + +In `packages/core/src/tools/webActionTools.ts`: + +3a. Add imports (top of file): + +```ts +import { + assessFill, + assessFormSubmission, + extractHostname, + type FirewallConfig, +} from "../security/actionFirewall.js"; +import type { + FirewallBlockedNonInteractiveEventData, + FirewallRemediation, +} from "../events.js"; +``` + +Replace the existing import line that brought in `assessFill, assessFormSubmission` with this combined import. + +3b. Extend `WebActionContext` (around line 24): + +```ts +interface WebActionContext { + browser: AriaBrowser; + eventEmitter: WebAgentEventEmitter; + providerConfig: ProviderConfig; + abortSignal?: AbortSignal; + approvedRefs?: ReadonlySet; + agentFilledRefs: Set; + operationalRefs: Set; + firewall: FirewallConfig; + interactive: boolean; +} +``` + +3c. Add a remediation builder helper (top of file, after `EMPTY_APPROVED_REFS`): + +```ts +function buildRemediations(blockedHostnames: string[]): FirewallRemediation[] { + const uniqueHosts = Array.from(new Set(blockedHostnames.filter((h): h is string => Boolean(h)))); + return [ + { + kind: "add-trusted-hostnames", + hostnames: uniqueHosts, + description: + uniqueHosts.length > 0 + ? `Add ${uniqueHosts.join(", ")} to trusted_hostnames to allow this action on this site.` + : "Add the page hostname to trusted_hostnames to allow this action on this site.", + }, + { + kind: "enable-interactive-mode", + description: + "Run in interactive mode by providing a UserDataCallback so the agent can ask the user to approve sensitive fields per-action via request_user_data.", + }, + { + kind: "enable-unsafe-mode", + description: + "Set unsafe_mode=true to disable the action firewall entirely. WARNING: prompt injection from page content can then drive the agent to submit any field, including personal and credential data, to attacker-controlled forms.", + }, + ]; +} + +function emitNonInteractiveBlock( + context: WebActionContext, + kind: "freeform-fill" | "form-submission", + reason: string, + pageHostname: string | null, + formActionHostnames: string[], +): void { + if (context.interactive) return; + const data: FirewallBlockedNonInteractiveEventData = { + timestamp: Date.now(), + iterationId: "", // populated by the eventEmitter middleware that adds iterationId; if no middleware, leave empty + reason, + kind, + pageHostname, + formActionHostnames, + remediations: buildRemediations( + pageHostname === null ? formActionHostnames : [pageHostname, ...formActionHostnames], + ), + }; + context.eventEmitter.emit(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, data); +} +``` + +If the existing event emit pattern in the file sets `iterationId` via a wrapper (search for `iterationId:` in this file), match that pattern. + +3d. Update the `fill.execute` handler (around line 228) to compute page hostname and call the assessment with bypass inputs, and emit on block: + +Replace the existing `fill.execute` body with: + +```ts +execute: async ({ ref, value }) => { + try { + const [metadata, pageUrl] = await Promise.all([ + context.browser.getFieldMetadata(ref), + context.browser.getUrl(), + ]); + const pageHostname = extractHostname(pageUrl); + const userApproved = Boolean(context.approvedRefs?.has(ref)); + const assessment = assessFill({ + field: metadata, + source: userApproved ? "user-approved" : "agent", + pageHostname, + firewall: context.firewall, + }); + + if (!assessment.allowed) { + emitNonInteractiveBlock(context, "freeform-fill", assessment.reason, pageHostname, []); + return failedActionResult(PageAction.Fill, assessment.reason, context, ref); + } + + const result = await performActionWithValidation(PageAction.Fill, context, ref, value); + if (result.success && !userApproved) { + context.agentFilledRefs.add(ref); + if (assessment.operational) { + context.operationalRefs.add(ref); + } + } + return result; + } catch (error) { + if (error instanceof BrowserException) { + return failedActionResult(PageAction.Fill, error.message, context, ref); + } + throw error; + } +}, +``` + +3e. Update `assessFormSubmissionForAction` (around line 78) similarly: + +```ts +async function assessFormSubmissionForAction( + action: PageAction.Click | PageAction.Enter, + context: WebActionContext, + ref: string, +): Promise { + try { + const [form, pageUrl] = await Promise.all([ + context.browser.getFormSubmissionContext( + ref, + action === PageAction.Click ? "click" : "enter", + ), + context.browser.getUrl(), + ]); + if (!form) return null; + const pageHostname = extractHostname(pageUrl); + const formActionHostnames = [ + extractHostname(form.actionUrl), + extractHostname(form.submitterActionUrl), + ].filter((h): h is string => h !== null); + + const assessment = assessFormSubmission({ + form, + approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, + agentFilledRefs: context.agentFilledRefs, + operationalRefs: context.operationalRefs, + pageHostname, + firewall: context.firewall, + }); + + if (!assessment.allowed) { + emitNonInteractiveBlock( + context, + "form-submission", + assessment.reason, + pageHostname, + formActionHostnames, + ); + return failedActionResult(action, assessment.reason, context, ref); + } + } catch (error) { + if (error instanceof BrowserException) { + return failedActionResult(action, error.message, context, ref); + } + throw error; + } + + return null; +} +``` + +3f. Confirm the `createWebActionTools` guard around line 203 still validates required fields. Update it to include `firewall`: + +```ts +export function createWebActionTools(context: WebActionContext) { + if (!context.agentFilledRefs || !context.operationalRefs) { + throw new Error("Web action provenance tracking sets are required"); + } + if (!context.firewall) { + throw new Error("FirewallConfig is required on WebActionContext"); + } + if (typeof context.interactive !== "boolean") { + throw new Error("interactive flag is required on WebActionContext"); + } + ... +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `pnpm --dir packages/core exec vitest run test/tools/webActionTools.test.ts -t "firewall bypass"` +Expected: PASS. + +Run: `pnpm --filter pilo-core run typecheck` +Expected: FAIL — `webAgent.ts` does not yet pass `firewall` or `interactive` to `createWebActionTools`. This is fixed in Task 6. + +- [ ] **Step 5: Commit (typecheck failure intentional until Task 6)** + +```bash +git add packages/core/src/tools/webActionTools.ts packages/core/test/tools/webActionTools.test.ts +git commit -m "feat(core): plumb FirewallConfig and interactive flag into web action tools" +``` + +--- + +## Task 6: WebAgent option additions and FirewallConfig construction + +**Files:** +- Modify: `packages/core/src/webAgent.ts` +- Modify: `packages/core/test/webAgent.test.ts` + +- [ ] **Step 1: Write failing integration tests** + +Append to `packages/core/test/webAgent.test.ts`. Locate the existing test setup pattern (`createWebAgent` / `WebAgent.execute` style) and add: + +```ts +describe("WebAgent firewall options", () => { + it("trustedHostnames flows into firewall config", async () => { + // Setup an agent with trustedHostnames=["example.com"]. + // Mock the model to issue a fill action on a textarea on a page at https://example.com/. + // Assert: the fill is allowed and the action result is success. + }); + + it("unsafeMode flows into firewall config", async () => { + // Setup an agent with unsafeMode=true. + // Mock the model to issue a fill on a textarea on https://untrusted.com/. + // Assert: the fill is allowed. + }); + + it("invalid hostname in trustedHostnames throws at agent construction", () => { + expect(() => createWebAgent({ trustedHostnames: ["bad value"] })).toThrow(/Invalid hostname/); + }); + + it("interactive flag is set from onUserDataRequired presence", async () => { + // Setup an agent without onUserDataRequired. Trigger a firewall-blocked fill. + // Assert: FIREWALL_BLOCKED_NON_INTERACTIVE is emitted. + + // Setup another agent with a stub onUserDataRequired. Trigger a firewall-blocked fill. + // Assert: FIREWALL_BLOCKED_NON_INTERACTIVE is NOT emitted. + }); + + it("existing prompt-injection regression still blocks on non-trusted page with both bypasses off", async () => { + // Existing regression scenario from the prior plan: ensure it still blocks. + }); +}); +``` + +Replace the commented assertions with actual code following the conventions used elsewhere in `webAgent.test.ts`. Look at the existing `prompt injection` regression test to see how the model and browser are mocked. + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `pnpm --dir packages/core exec vitest run test/webAgent.test.ts -t "WebAgent firewall options"` +Expected: FAIL — options don't exist. + +- [ ] **Step 3: Add the new options to `WebAgentOptions` and build `FirewallConfig`** + +In `packages/core/src/webAgent.ts`: + +3a. Add imports (top of file): + +```ts +import { + normalizeHostname, + type FirewallConfig, +} from "./security/actionFirewall.js"; +``` + +3b. Extend `WebAgentOptions` (around line 66). Add two new fields with TSDoc warnings: + +```ts +/** + * Hostnames where the action firewall is bypassed for fills and submissions. + * + * @warning On listed hosts, prompt injection from page content can drive the + * agent to fill and submit any field, including personal and credential data. + * Use only for sites you fully trust to receive your data. The bypass applies + * only when the current page hostname AND every form-action hostname (the + * form's `action` plus any submitter `formaction` override) are all in this + * list. + */ +trustedHostnames?: readonly string[]; + +/** + * Disables the action firewall entirely. + * + * @warning When true, prompt injection from page content can cause the agent + * to submit your data, including credentials, personal information, and + * conversation context, to attacker-controlled forms. Only enable for + * trusted, controlled environments. + */ +unsafeMode?: boolean; +``` + +3c. Build a frozen `FirewallConfig` at task setup. Locate the section of `WebAgent` constructor or task-start path where other config-like values are normalized (search for `options.guardrails` or similar pattern). Add a helper near the top of the class or as a module function: + +```ts +function buildFirewallConfig(options: WebAgentOptions): FirewallConfig { + const rawHostnames = options.trustedHostnames ?? []; + const normalized = rawHostnames.map((entry) => normalizeHostname(entry)); + return Object.freeze({ + trustedHostnames: new Set(normalized), + unsafeMode: Boolean(options.unsafeMode), + }); +} +``` + +3d. Wire `FirewallConfig` and `interactive` into the `createWebActionTools` call. Locate the existing call (around line 407 — search for `createWebActionTools(`) and update: + +```ts +const firewall = buildFirewallConfig(options); +const interactive = Boolean(options.onUserDataRequired); + +... + +const webActionTools = createWebActionTools({ + browser, + eventEmitter, + providerConfig: options.providerConfig, + abortSignal, + approvedRefs: approvedRefs ?? undefined, + agentFilledRefs, + operationalRefs, + firewall, + interactive, +}); +``` + +If the existing structure builds `WebActionContext` differently (e.g., a constructor pattern), match that pattern. `firewall` and `interactive` must be set before `createWebActionTools` is called. + +Ensure `buildFirewallConfig` runs synchronously before the agent loop starts so a bad hostname surfaces immediately to the caller. + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `pnpm --dir packages/core exec vitest run test/webAgent.test.ts -t "WebAgent firewall options"` +Expected: PASS. + +Run: `pnpm --filter pilo-core run typecheck` +Expected: PASS. + +Run: `pnpm --filter pilo-core run test` +Expected: PASS (existing tests should still pass; regression test should still block). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/webAgent.ts packages/core/test/webAgent.test.ts +git commit -m "feat(core): add trustedHostnames and unsafeMode to WebAgentOptions" +``` + +--- + +## Task 7: Config defaults — `trusted_hostnames` and `unsafe_mode` + +**Files:** +- Modify: `packages/core/src/config/defaults.ts` +- Modify: `packages/core/test/config/*.test.ts` (add tests next to existing config tests) + +- [ ] **Step 1: Write failing config tests** + +Look in `packages/core/test/config/` for an existing test file (e.g., `defaults.test.ts` or similar). If none exists for parsing, create `packages/core/test/config/defaults.test.ts`. Add: + +```ts +import { describe, it, expect } from "vitest"; +import { FIELDS, DEFAULTS } from "../../src/config/defaults.js"; + +describe("config defaults: firewall fields", () => { + it("declares trusted_hostnames as string[] with empty default", () => { + expect(FIELDS.trusted_hostnames).toBeDefined(); + expect(FIELDS.trusted_hostnames.type).toBe("string[]"); + expect(FIELDS.trusted_hostnames.category).toBe("action"); + expect(DEFAULTS.trusted_hostnames).toEqual([]); + }); + + it("declares unsafe_mode as boolean with false default", () => { + expect(FIELDS.unsafe_mode).toBeDefined(); + expect(FIELDS.unsafe_mode.type).toBe("boolean"); + expect(FIELDS.unsafe_mode.category).toBe("action"); + expect(DEFAULTS.unsafe_mode).toBe(false); + }); + + it("trusted_hostnames description warns about data risk", () => { + expect(FIELDS.trusted_hostnames.description).toMatch(/WARNING/); + expect(FIELDS.trusted_hostnames.description.toLowerCase()).toContain("trust"); + }); + + it("unsafe_mode description warns about data risk", () => { + expect(FIELDS.unsafe_mode.description).toMatch(/WARNING/); + expect(FIELDS.unsafe_mode.description.toLowerCase()).toContain("firewall"); + }); +}); +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `pnpm --dir packages/core exec vitest run test/config/defaults.test.ts -t "firewall fields"` +Expected: FAIL — fields not declared. + +- [ ] **Step 3: Add the fields to `PiloConfig`, `PiloConfigWithDefaults`, `FIELDS`, and `DEFAULTS`** + +In `packages/core/src/config/defaults.ts`: + +3a. Add to the `PiloConfig` input interface (find the existing `action` block; add after `action_timeout_ms`): + +```ts +trusted_hostnames?: string[]; +unsafe_mode?: boolean; +``` + +3b. Add to the `PiloConfigWithDefaults` resolved interface (mirror the same position): + +```ts +trusted_hostnames: string[]; +unsafe_mode: boolean; +``` + +3c. Add to the `FIELDS` registry (after the `action_timeout_ms` entry, in the same `action` category block): + +```ts +trusted_hostnames: { + default: [], + type: "string[]", + cli: "--trusted-hostnames", + placeholder: "host1,host2,...", + env: ["PILO_TRUSTED_HOSTNAMES"], + description: + "Comma-separated hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, prompt injection from page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data.", + category: "action", +}, +unsafe_mode: { + default: false, + type: "boolean", + cli: "--unsafe", + env: ["PILO_UNSAFE_MODE"], + description: + "Disables the action firewall entirely. WARNING: prompt injection from page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments.", + category: "action", +}, +``` + +3d. Add to the `DEFAULTS` constant (mirror position): + +```ts +trusted_hostnames: [], +unsafe_mode: false, +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `pnpm --dir packages/core exec vitest run test/config/defaults.test.ts -t "firewall fields"` +Expected: PASS. + +Run: `pnpm --filter pilo-core run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/config/defaults.ts packages/core/test/config/defaults.test.ts +git commit -m "feat(core): add trusted_hostnames and unsafe_mode config fields" +``` + +--- + +## Task 8: CLI + env wiring (verify no code changes needed) + +**Files:** +- Verify: `packages/core/src/config/commander.ts` +- Verify: `packages/core/src/config/env.ts` +- Modify: `packages/core/test/config/` (add CLI + env tests if not present) + +The generic `addConfigOptions` in `commander.ts` and `parseEnvConfig` in `env.ts` already handle `string[]` and `boolean` field types. No source changes are expected — verify by test. + +- [ ] **Step 1: Write CLI tests** + +Add to (or create) `packages/core/test/config/commander.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { Command } from "commander"; +import { addConfigOptions } from "../../src/config/commander.js"; + +describe("CLI: firewall flags", () => { + it("parses --trusted-hostnames as comma-separated list", () => { + const cmd = new Command().exitOverride(); + addConfigOptions(cmd); + cmd.action(() => {}); + cmd.parse(["node", "test", "--trusted-hostnames", "a.com,b.com"]); + const opts = cmd.opts(); + expect(opts.trustedHostnames).toEqual(["a.com", "b.com"]); + }); + + it("parses --unsafe as boolean true", () => { + const cmd = new Command().exitOverride(); + addConfigOptions(cmd); + cmd.action(() => {}); + cmd.parse(["node", "test", "--unsafe"]); + const opts = cmd.opts(); + expect(opts.unsafe).toBe(true); + }); +}); +``` + +(Commander converts kebab-case flags to camelCase option keys: `--trusted-hostnames` → `trustedHostnames`, `--unsafe` → `unsafe`.) + +- [ ] **Step 2: Write env tests** + +Add to (or create) `packages/core/test/config/env.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { parseEnvConfig } from "../../src/config/env.js"; + +describe("env: firewall fields", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PILO_TRUSTED_HOSTNAMES; + delete process.env.PILO_UNSAFE_MODE; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("parses PILO_TRUSTED_HOSTNAMES as comma-separated list", () => { + process.env.PILO_TRUSTED_HOSTNAMES = "a.com,b.com"; + const result = parseEnvConfig(); + expect(result.trusted_hostnames).toEqual(["a.com", "b.com"]); + }); + + it("parses PILO_UNSAFE_MODE=true as boolean true", () => { + process.env.PILO_UNSAFE_MODE = "true"; + const result = parseEnvConfig(); + expect(result.unsafe_mode).toBe(true); + }); + + it("ignores PILO_UNSAFE_MODE when unset", () => { + const result = parseEnvConfig(); + expect(result.unsafe_mode).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 3: Run tests, verify they pass without any source change** + +Run: `pnpm --dir packages/core exec vitest run test/config/commander.test.ts` +Run: `pnpm --dir packages/core exec vitest run test/config/env.test.ts` +Expected: PASS for both. + +If they fail with an unexpected reason (not "expected" vs "received"), investigate whether `addConfigOptions` or `parseEnvConfig` needs adjustment. They should not. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/test/config/commander.test.ts packages/core/test/config/env.test.ts +git commit -m "test(core): verify CLI flags and env vars for firewall config" +``` + +--- + +## Task 9: CLI consumer — pass config to WebAgent and print remediation footer + +**Files:** +- Modify: `packages/cli/src/commands/run.ts` +- Test: `packages/cli/test/commands/run.test.ts` (if test pattern exists; otherwise add a minimal unit test for the footer printer) + +- [ ] **Step 1: Locate the WebAgent construction in `pilo run`** + +Run: `grep -n "new WebAgent\|WebAgent(" packages/cli/src/commands/run.ts` + +Find the call where options are passed to `WebAgent` from the merged config. Add `trustedHostnames` and `unsafeMode`: + +```ts +const agent = new WebAgent({ + ...existingOptions, + trustedHostnames: config.trusted_hostnames, + unsafeMode: config.unsafe_mode, +}); +``` + +(Use the actual variable names from the file.) + +- [ ] **Step 2: Subscribe to the firewall event and print remediation** + +Locate the existing event subscription pattern in `run.ts` (search for `eventEmitter.on(` or `eventEmitter.onEvent(`). Add a new subscriber: + +```ts +import { WebAgentEventType, type FirewallBlockedNonInteractiveEventData } from "pilo-core"; + +// near the other eventEmitter.onEvent(...) calls: +eventEmitter.onEvent( + WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, + (data: FirewallBlockedNonInteractiveEventData) => { + printFirewallRemediation(data); + }, +); +``` + +Add the helper near the bottom of the file (or in a small adjacent module if `run.ts` is large): + +```ts +function printFirewallRemediation(data: FirewallBlockedNonInteractiveEventData): void { + const lines: string[] = []; + lines.push(""); + lines.push("Pilo: an action was blocked by the prompt-injection firewall."); + lines.push(`Reason: ${data.reason}`); + if (data.pageHostname || data.formActionHostnames.length > 0) { + const hosts = [data.pageHostname, ...data.formActionHostnames] + .filter((h): h is string => Boolean(h)) + .filter((h, i, a) => a.indexOf(h) === i); + if (hosts.length > 0) { + lines.push(`Hostnames involved: ${hosts.join(", ")}`); + } + } + lines.push("To allow this action, you can:"); + for (const r of data.remediations) { + if (r.kind === "add-trusted-hostnames") { + const cmd = + r.hostnames.length > 0 + ? `pilo config set trusted_hostnames ${r.hostnames.join(",")}` + : "pilo config set trusted_hostnames "; + lines.push(` - ${r.description} Run: ${cmd}`); + } else if (r.kind === "enable-interactive-mode") { + lines.push(` - ${r.description}`); + } else if (r.kind === "enable-unsafe-mode") { + lines.push(` - ${r.description} Run: pilo config set unsafe_mode true`); + } + } + // Use the project's existing logging convention. If the file uses console.warn for similar warnings, + // use console.warn. Otherwise use the project logger. + for (const line of lines) { + console.warn(line); + } +} +``` + +If the file uses a different logging primitive (e.g., a chalk-styled error stream), use that instead. The footer must be distinguishable from the model's tool-result line. + +- [ ] **Step 3: Add a unit test for the footer printer** + +If a test pattern exists for run.ts, add a test in the matching location. Otherwise, create `packages/cli/test/commands/run.test.ts` with a minimal test: + +```ts +import { describe, it, expect, vi, afterEach } from "vitest"; +import { printFirewallRemediation } from "../../src/commands/run.js"; +import type { FirewallBlockedNonInteractiveEventData } from "pilo-core"; + +describe("printFirewallRemediation", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("prints all three remediation options with the blocked hostname", () => { + const data: FirewallBlockedNonInteractiveEventData = { + timestamp: Date.now(), + iterationId: "", + reason: "Security policy blocked submitting a form containing unauthorized agent-filled data", + kind: "form-submission", + pageHostname: "untrusted.com", + formActionHostnames: ["untrusted.com"], + remediations: [ + { + kind: "add-trusted-hostnames", + hostnames: ["untrusted.com"], + description: "Add untrusted.com to trusted_hostnames to allow this action on this site.", + }, + { + kind: "enable-interactive-mode", + description: "Run in interactive mode by providing a UserDataCallback...", + }, + { + kind: "enable-unsafe-mode", + description: "Set unsafe_mode=true to disable the action firewall entirely...", + }, + ], + }; + + printFirewallRemediation(data); + const output = warnSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("trusted_hostnames untrusted.com"); + expect(output).toContain("interactive mode"); + expect(output).toContain("unsafe_mode true"); + expect(output).toContain("untrusted.com"); + }); +}); +``` + +For this test to work, `printFirewallRemediation` must be exported from `run.ts` (add `export` to the function declaration). + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `pnpm --filter pilo-cli run test` +Expected: PASS. + +Run: `pnpm --filter pilo-cli run typecheck` (or `pnpm run typecheck` from the root if there is no per-package typecheck script) +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/run.ts packages/cli/test/commands/run.test.ts +git commit -m "feat(cli): wire firewall config and print non-interactive remediation footer" +``` + +--- + +## Task 10: Documentation — TSDoc and README + +**Files:** +- Modify: `README.md` (root) +- Verify TSDoc already added in Task 6 (`packages/core/src/webAgent.ts`) + +- [ ] **Step 1: Add a "Security model" subsection to the root README** + +Locate the existing top-level sections in `README.md`. Add a new subsection — placement is the project owner's call, but a reasonable spot is after the high-level "How it works" / "Features" section. + +Append this subsection (adapt heading level to match the surrounding doc): + +```markdown +## Security model + +Pilo treats every web page as untrusted input. By default, the **action firewall** prevents the agent from filling freeform form fields (textareas, contact-info inputs, password fields, etc.) and from submitting any form containing agent-filled values that the user did not explicitly approve. This is the structural defense against prompt-injection attacks where a page tries to coax the agent into exfiltrating data via a form. + +Two caller-supplied controls relax this protection. Both are off by default. **Enabling either weakens the firewall's data-protection guarantees.** + +### `trusted_hostnames` + +A list of hostnames on which the firewall is bypassed for fills and submissions. The bypass applies only when the current page hostname **and every form-action hostname** (the form's `action` plus any submitter `formaction` override) are all in the list. + +```bash +pilo config set trusted_hostnames example.com,app.example.com +``` + +WARNING: on listed hosts, prompt injection from page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. + +### `unsafe_mode` + +A global firewall disable. When enabled, neither the fill gate nor the submit gate applies, regardless of page or form-action hostname. + +```bash +pilo config set unsafe_mode true +``` + +WARNING: prompt injection from page content can then cause the agent to submit your data, including credentials, personal information, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. + +### Remediation when a block fires + +When the firewall blocks a fill or submission and the agent is not running in interactive mode (no `UserDataCallback`), the CLI prints a footer listing the three ways the user can enable the workflow: + +- Add the involved hostnames to `trusted_hostnames`. +- Run in interactive mode so the agent can request per-field approval through `request_user_data`. +- Enable `unsafe_mode` (with the data-protection warning above). + +The footer is shown only to the user; the model that drives the agent never sees these remediation suggestions, so prompt-injected page content cannot ask the user to enable the bypasses. +``` + +- [ ] **Step 2: Verify TSDoc was added in Task 6** + +Run: `grep -A 5 "trustedHostnames?:" packages/core/src/webAgent.ts` +Run: `grep -A 5 "unsafeMode?:" packages/core/src/webAgent.ts` +Expected: each shows a TSDoc block with `@warning` referencing the data-protection caveat. + +If the TSDoc is missing or incomplete, add it now (copy from Task 6 Step 3b). + +- [ ] **Step 3: Commit** + +```bash +git add README.md packages/core/src/webAgent.ts +git commit -m "docs: document action firewall and bypass controls" +``` + +--- + +## Task 11: Final validation + +**Files:** none (validation only) + +- [ ] **Step 1: Format** + +Run: `pnpm run format` +Expected: clean exit. + +- [ ] **Step 2: Typecheck** + +Run: `pnpm run typecheck` +Expected: PASS. + +- [ ] **Step 3: Full test suite** + +Run: `pnpm -r run test` +Expected: PASS. + +- [ ] **Step 4: Format check** + +Run: `pnpm run format:check` +Expected: PASS. + +- [ ] **Step 5: Gitleaks scan** + +Run: `gitleaks protect -v` +Expected: no leaks. If `gitleaks` is not installed locally, run `brew install gitleaks` first. + +Run: `gitleaks detect -v` +Expected: no leaks. (Existing `.gitleaksignore` entries handle historical false positives.) + +- [ ] **Step 6: Manual smoke test (one CLI run per bypass surface)** + +Run: `pnpm pilo config set trusted_hostnames example.com` +Expected: persists; `pilo config get trusted_hostnames` prints `example.com`. + +Run: `pnpm pilo config set trusted_hostnames "bad value"` +Expected: error message naming the bad entry; exit non-zero. + +Run: `pnpm pilo config unset trusted_hostnames` +Expected: clean. + +Run: `pnpm pilo --help | grep -E "trusted-hostnames|unsafe"` +Expected: both flags appear with their warning-laden descriptions. + +- [ ] **Step 7: Commit any format-only changes** + +```bash +git status +# if format made changes: +git add -A +git commit -m "chore: prettier pass after firewall bypass work" +``` + +--- + +## Out of scope + +- Wildcard / subdomain matching for trusted hostnames. +- Per-field trust overrides beyond `request_user_data`. +- Runtime banner UI for bypassed actions (documentation is the compensating control). +- Reputation- or heuristic-based trust. + +## Self-Review + +- **Spec coverage:** + - Trusted-hostname bypass conditions (page hostname + all form-action hostnames must match): covered in Task 2 (firewall logic) and Task 5 (tool plumbing). + - `unsafeMode` global disable: Task 2, Task 5. + - `submitterActionUrl` resolution: Task 3. + - User-facing remediation on block in non-interactive mode: Task 5 (event emission) and Task 9 (CLI footer). + - Model isolation (no remediation in tool result): Task 5 (test asserts `result.error` does not include `unsafe_mode`/`trusted_hostnames`/blocked hostnames). + - Hostname normalization with validation at agent construction: Task 1 (helpers) and Task 6 (called from `buildFirewallConfig`). + - Config field defaults: Task 7. + - CLI/env wiring: Task 8 (verified via tests). + - CLI consumer prints remediation: Task 9. + - TSDoc + README: Tasks 6 and 10. +- **Placeholder scan:** none ("TBD", "TODO", "implement later" not present). +- **Type consistency:** `FirewallConfig`, `assessFill`, `assessFormSubmission` signatures match across tasks. `FirewallRemediation` shape matches between Task 4 (event-data type) and Task 5 (`buildRemediations`) and Task 9 (CLI printer). `FormSubmissionContext.submitterActionUrl` introduced in Task 3 and consumed in Tasks 2 and 5. +- **Compile-fix gap:** Task 5 ends in a deliberate `webAgent.ts` typecheck failure that Task 6 fixes. Implementers must execute Tasks 5 and 6 together (or accept the intermediate red typecheck between commits) rather than stopping at Task 5. From eb332f8660f9189bc3001a7a80474ed9d8f1bcf9 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 13:42:51 -0400 Subject: [PATCH 25/44] feat(core): add hostname normalization and extraction helpers --- packages/core/src/security/actionFirewall.ts | 49 ++++++++ .../core/test/security/actionFirewall.test.ts | 108 ++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts index 0e64dd74..1967cc07 100644 --- a/packages/core/src/security/actionFirewall.ts +++ b/packages/core/src/security/actionFirewall.ts @@ -137,3 +137,52 @@ function hasSensitiveAutocomplete(autocomplete: string | null): boolean { const tokens = autocomplete.toLowerCase().split(/\s+/); return tokens.some((token) => SENSITIVE_AUTOCOMPLETE_TOKENS.has(token)); } + +export class InvalidHostnameError extends Error { + constructor(input: string, reason: string) { + super(`Invalid hostname "${input}": ${reason}`); + this.name = "InvalidHostnameError"; + } +} + +const HOSTNAME_DISALLOWED_CHARS = /[\s/:*]/; + +export function normalizeHostname(input: string): string { + if (typeof input !== "string") { + throw new InvalidHostnameError(String(input), "not a string"); + } + const trimmed = input.trim(); + if (trimmed.length === 0) { + throw new InvalidHostnameError(input, "empty"); + } + if (HOSTNAME_DISALLOWED_CHARS.test(trimmed)) { + throw new InvalidHostnameError(input, "contains whitespace, '/', ':', or '*'"); + } + if (trimmed.startsWith("[") || trimmed.endsWith("]")) { + throw new InvalidHostnameError(input, "bracketed IPv6 is not supported"); + } + let withoutTrailingDot = trimmed; + if (withoutTrailingDot.endsWith(".")) { + withoutTrailingDot = withoutTrailingDot.slice(0, -1); + } + if (withoutTrailingDot.length === 0) { + throw new InvalidHostnameError(input, "empty after trimming trailing dot"); + } + return withoutTrailingDot.toLowerCase(); +} + +export function extractHostname(url: string | null): string | null { + if (url === null || url === undefined) return null; + if (typeof url !== "string" || url.length === 0) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + let host = parsed.hostname.toLowerCase(); + if (host.endsWith(".")) host = host.slice(0, -1); + if (host.length === 0) return null; + return host; +} diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts index 721d928a..ff36cc4a 100644 --- a/packages/core/test/security/actionFirewall.test.ts +++ b/packages/core/test/security/actionFirewall.test.ts @@ -3,6 +3,9 @@ import type { FieldMetadata, FormSubmissionContext } from "../../src/browser/ari import { assessFill, assessFormSubmission, + normalizeHostname, + extractHostname, + InvalidHostnameError, SECURITY_BLOCKED_UNAUTHORIZED_FILL, SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, } from "../../src/security/actionFirewall.js"; @@ -158,3 +161,108 @@ describe("actionFirewall", () => { expect(result.allowed).toBe(true); }); }); + +describe("normalizeHostname", () => { + it("lowercases input", () => { + expect(normalizeHostname("Example.COM")).toBe("example.com"); + }); + + it("strips a single trailing dot", () => { + expect(normalizeHostname("example.com.")).toBe("example.com"); + }); + + it("accepts bare hostnames", () => { + expect(normalizeHostname("app.example.com")).toBe("app.example.com"); + }); + + it("accepts IDN punycode", () => { + expect(normalizeHostname("xn--mnich-kva.de")).toBe("xn--mnich-kva.de"); + }); + + it("accepts bare IPv4 literals", () => { + expect(normalizeHostname("127.0.0.1")).toBe("127.0.0.1"); + }); + + it("rejects empty string", () => { + expect(() => normalizeHostname("")).toThrow(InvalidHostnameError); + }); + + it("rejects whitespace-only", () => { + expect(() => normalizeHostname(" ")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with whitespace", () => { + expect(() => normalizeHostname("ex ample.com")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with slashes", () => { + expect(() => normalizeHostname("example.com/path")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with colons", () => { + expect(() => normalizeHostname("example.com:8080")).toThrow(InvalidHostnameError); + }); + + it("rejects strings with wildcards", () => { + expect(() => normalizeHostname("*.example.com")).toThrow(InvalidHostnameError); + }); + + it("rejects URL inputs with scheme", () => { + expect(() => normalizeHostname("https://example.com")).toThrow(InvalidHostnameError); + }); + + it("rejects bracketed IPv6 in v1", () => { + expect(() => normalizeHostname("[::1]")).toThrow(InvalidHostnameError); + }); + + it("error message names the bad entry", () => { + try { + normalizeHostname("bad value"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidHostnameError); + expect((e as Error).message).toContain("bad value"); + } + }); +}); + +describe("extractHostname", () => { + it("returns lowercase hostname for https URLs", () => { + expect(extractHostname("https://Example.COM/path?q=1")).toBe("example.com"); + }); + + it("returns lowercase hostname for http URLs", () => { + expect(extractHostname("http://app.example.com")).toBe("app.example.com"); + }); + + it("strips trailing dot", () => { + expect(extractHostname("https://example.com./")).toBe("example.com"); + }); + + it("returns null for null input", () => { + expect(extractHostname(null)).toBeNull(); + }); + + it("returns null for about:blank", () => { + expect(extractHostname("about:blank")).toBeNull(); + }); + + it("returns null for data: URLs", () => { + expect(extractHostname("data:text/html,

x

")).toBeNull(); + }); + + it("returns null for file: URLs", () => { + expect(extractHostname("file:///tmp/foo.html")).toBeNull(); + }); + + it("returns null for javascript: URLs", () => { + expect(extractHostname("javascript:alert(1)")).toBeNull(); + }); + + it("returns null for malformed URLs", () => { + expect(extractHostname("not a url")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(extractHostname("")).toBeNull(); + }); +}); From f1f5c86b330bb263bffaeae192df453c4fbebb46 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 13:48:36 -0400 Subject: [PATCH 26/44] feat(core): add FirewallConfig and bypass branches to action firewall Adds FirewallConfig interface (trustedHostnames + unsafeMode) to assessFill and assessFormSubmission with unsafeMode/trusted-hostname bypass paths. Forward-declares FormSubmissionContext.submitterActionUrl (null) across all implementations to unblock tests; Task 3 will populate the real value via Playwright. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/browser/ariaBrowser.ts | 1 + .../core/src/browser/playwrightBrowser.ts | 1 + packages/core/src/security/actionFirewall.ts | 41 ++++ packages/core/src/tools/webActionTools.ts | 4 + .../core/test/security/actionFirewall.test.ts | 193 ++++++++++++++++++ .../core/test/tools/webActionTools.test.ts | 3 + .../src/background/ExtensionBrowser.ts | 1 + 7 files changed, 244 insertions(+) diff --git a/packages/core/src/browser/ariaBrowser.ts b/packages/core/src/browser/ariaBrowser.ts index 0ec38656..0df57af0 100644 --- a/packages/core/src/browser/ariaBrowser.ts +++ b/packages/core/src/browser/ariaBrowser.ts @@ -84,6 +84,7 @@ export interface FormSubmissionContext { submitterRef: string; formId: string | null; actionUrl: string | null; + submitterActionUrl: string | null; method: string | null; fields: FormFieldState[]; } diff --git a/packages/core/src/browser/playwrightBrowser.ts b/packages/core/src/browser/playwrightBrowser.ts index 8a7cb3c5..6ba1d421 100644 --- a/packages/core/src/browser/playwrightBrowser.ts +++ b/packages/core/src/browser/playwrightBrowser.ts @@ -933,6 +933,7 @@ export class PlaywrightBrowser implements AriaBrowser { submitterRef, formId: form.id || null, actionUrl: form.action || null, + submitterActionUrl: null, method: form.method?.toLowerCase() || null, fields, }; diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts index 1967cc07..90fa51cb 100644 --- a/packages/core/src/security/actionFirewall.ts +++ b/packages/core/src/security/actionFirewall.ts @@ -12,6 +12,11 @@ export type ActionFirewallResult = | { allowed: true; operational?: boolean } | { allowed: false; reason: string; isRecoverable: true }; +export interface FirewallConfig { + trustedHostnames: ReadonlySet; + unsafeMode: boolean; +} + const OPERATIONAL_INPUT_TYPES = new Set([ "search", "number", @@ -85,7 +90,20 @@ const SENSITIVE_AUTOCOMPLETE_TOKENS = new Set([ export function assessFill(input: { field: FieldMetadata; source: FillSource; + pageHostname: string | null; + firewall: FirewallConfig; }): ActionFirewallResult { + if (input.firewall.unsafeMode) { + return { allowed: true }; + } + + if ( + input.pageHostname !== null && + input.firewall.trustedHostnames.has(input.pageHostname) + ) { + return { allowed: true }; + } + if (input.source === "user-approved") { return { allowed: true }; } @@ -106,7 +124,30 @@ export function assessFormSubmission(input: { approvedRefs: ReadonlySet; agentFilledRefs: ReadonlySet; operationalRefs: ReadonlySet; + pageHostname: string | null; + firewall: FirewallConfig; }): ActionFirewallResult { + if (input.firewall.unsafeMode) { + return { allowed: true }; + } + + if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { + const formActionHost = extractHostname(input.form.actionUrl); + const submitterActionHost = extractHostname(input.form.submitterActionUrl); + + const formActionTrusted = + formActionHost !== null && input.firewall.trustedHostnames.has(formActionHost); + + const submitterTrusted = + input.form.submitterActionUrl === null + ? true + : submitterActionHost !== null && input.firewall.trustedHostnames.has(submitterActionHost); + + if (formActionTrusted && submitterTrusted) { + return { allowed: true }; + } + } + for (const field of input.form.fields) { if (!field.ref || !input.agentFilledRefs.has(field.ref)) continue; if (input.approvedRefs.has(field.ref) || input.operationalRefs.has(field.ref)) continue; diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index 9e8f113d..609b8921 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -92,6 +92,8 @@ async function assessFormSubmissionForAction( approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, agentFilledRefs: context.agentFilledRefs, operationalRefs: context.operationalRefs, + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); if (!assessment.allowed) { @@ -232,6 +234,8 @@ export function createWebActionTools(context: WebActionContext) { const assessment = assessFill({ field: metadata, source: userApproved ? "user-approved" : "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); if (!assessment.allowed) { diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts index ff36cc4a..8d99eab2 100644 --- a/packages/core/test/security/actionFirewall.test.ts +++ b/packages/core/test/security/actionFirewall.test.ts @@ -8,6 +8,7 @@ import { InvalidHostnameError, SECURITY_BLOCKED_UNAUTHORIZED_FILL, SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, + type FirewallConfig, } from "../../src/security/actionFirewall.js"; function field(overrides: Partial = {}): FieldMetadata { @@ -33,6 +34,7 @@ function form(overrides: Partial = {}): FormSubmissionCon submitterRef: "E9", formId: "form-1", actionUrl: "https://example.com/submit", + submitterActionUrl: null, method: "post", fields: [], ...overrides, @@ -44,6 +46,8 @@ describe("actionFirewall", () => { const result = assessFill({ field: field({ inputType: "search", label: "Search products" }), source: "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(true); @@ -55,6 +59,8 @@ describe("actionFirewall", () => { const result = assessFill({ field: field({ label: "Message" }), source: "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(false); @@ -66,6 +72,8 @@ describe("actionFirewall", () => { const result = assessFill({ field: field({ inputType: "text", label: "Search products", placeholder: "Search" }), source: "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(false); @@ -75,6 +83,8 @@ describe("actionFirewall", () => { const result = assessFill({ field: field({ tagName: "textarea", inputType: null, role: "searchbox" }), source: "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(false); @@ -84,6 +94,8 @@ describe("actionFirewall", () => { const result = assessFill({ field: field({ inputType: "url", autocomplete: "url" }), source: "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(false); @@ -93,6 +105,8 @@ describe("actionFirewall", () => { const result = assessFill({ field: field({ inputType: "url", autocomplete: null }), source: "agent", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(false); @@ -104,6 +118,8 @@ describe("actionFirewall", () => { const result = assessFill({ field: field({ label: "Message" }), source: "user-approved", + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(true); @@ -125,6 +141,8 @@ describe("actionFirewall", () => { approvedRefs: new Set(), agentFilledRefs: new Set(["E1"]), operationalRefs: new Set(), + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(false); @@ -156,6 +174,8 @@ describe("actionFirewall", () => { approvedRefs: new Set(["E2"]), agentFilledRefs: new Set(["E1", "E2"]), operationalRefs: new Set(["E1"]), + pageHostname: null, + firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); expect(result.allowed).toBe(true); @@ -266,3 +286,176 @@ describe("extractHostname", () => { expect(extractHostname("")).toBeNull(); }); }); + +const freeformField: FieldMetadata = { + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, +}; + +function withTrusted(hosts: string[]): FirewallConfig { + return { trustedHostnames: new Set(hosts), unsafeMode: false }; +} + +const unsafeFirewall: FirewallConfig = { + trustedHostnames: new Set(), + unsafeMode: true, +}; + +describe("assessFill bypass branches", () => { + it("unsafeMode allows any field regardless of source", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: null, + firewall: unsafeFirewall, + }); + expect(result.allowed).toBe(true); + }); + + it("trusted page hostname allows freeform fill", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(true); + }); + + it("untrusted page hostname falls through to existing rules and blocks freeform", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: "attacker.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("pageHostname=null never bypasses", () => { + const result = assessFill({ + field: freeformField, + source: "agent", + pageHostname: null, + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); +}); + +const baseForm: FormSubmissionContext = { + submitterRef: "submit-1", + formId: null, + actionUrl: "https://example.com/submit", + submitterActionUrl: null, + method: "post", + fields: [ + { + ref: "ref-1", + name: "comment", + tagName: "textarea", + inputType: null, + autocomplete: null, + }, + ], +}; + +describe("assessFormSubmission bypass branches", () => { + it("unsafeMode allows any form", () => { + const result = assessFormSubmission({ + form: baseForm, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "attacker.com", + firewall: unsafeFirewall, + }); + expect(result.allowed).toBe(true); + }); + + it("trusted page + trusted form action allows submission", () => { + const result = assessFormSubmission({ + form: baseForm, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(true); + }); + + it("trusted page + untrusted form action falls through and blocks", () => { + const result = assessFormSubmission({ + form: { ...baseForm, actionUrl: "https://attacker.com/exfil" }, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("trusted page + null form action hostname falls through", () => { + const result = assessFormSubmission({ + form: { ...baseForm, actionUrl: "about:blank" }, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("untrusted page + trusted form action falls through", () => { + const result = assessFormSubmission({ + form: baseForm, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "attacker.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("checks submitter action URL when present", () => { + const result = assessFormSubmission({ + form: { + ...baseForm, + actionUrl: "https://example.com/normal", + submitterActionUrl: "https://attacker.com/override", + }, + approvedRefs: new Set(), + agentFilledRefs: new Set(["ref-1"]), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(false); + }); + + it("falls through (no bypass) when nothing is agent-filled but submitter is untrusted", () => { + const result = assessFormSubmission({ + form: { ...baseForm, actionUrl: "https://attacker.com/exfil" }, + approvedRefs: new Set(), + agentFilledRefs: new Set(), + operationalRefs: new Set(), + pageHostname: "example.com", + firewall: withTrusted(["example.com"]), + }); + expect(result.allowed).toBe(true); // existing rule: no agent-filled => allowed + }); +}); diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index bae69ca0..993b4805 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -634,6 +634,7 @@ describe("Web Action Tools", () => { submitterRef: "submit1", formId: "contact", actionUrl: "https://example.com/contact", + submitterActionUrl: null, method: "post", fields: [ { @@ -666,6 +667,7 @@ describe("Web Action Tools", () => { submitterRef: "submit1", formId: "search", actionUrl: "https://example.com/search", + submitterActionUrl: null, method: "get", fields: [ { @@ -702,6 +704,7 @@ describe("Web Action Tools", () => { submitterRef: "input1", formId: "contact", actionUrl: "https://example.com/contact", + submitterActionUrl: null, method: "post", fields: [ { diff --git a/packages/extension/src/background/ExtensionBrowser.ts b/packages/extension/src/background/ExtensionBrowser.ts index 700ca7c7..b0c6ce09 100644 --- a/packages/extension/src/background/ExtensionBrowser.ts +++ b/packages/extension/src/background/ExtensionBrowser.ts @@ -488,6 +488,7 @@ export class ExtensionBrowser implements AriaBrowser { submitterRef, formId: form.id || null, actionUrl: form.action || null, + submitterActionUrl: null, method: form.method?.toLowerCase() || null, fields, }, From e643119138b053bacd9b7aab2f72304e40cce5e7 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 13:51:34 -0400 Subject: [PATCH 27/44] chore(core): mark firewall scaffolding literals as temporary --- packages/core/src/tools/webActionTools.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index 609b8921..e3b67c39 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -87,6 +87,8 @@ async function assessFormSubmissionForAction( ); if (!form) return null; + // TODO(firewall-bypass): replace with real pageHostname + context.firewall + // once webActionTools is plumbed in Task 5 of the firewall-bypass plan. const assessment = assessFormSubmission({ form, approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, @@ -231,6 +233,8 @@ export function createWebActionTools(context: WebActionContext) { try { const metadata = await context.browser.getFieldMetadata(ref); const userApproved = Boolean(context.approvedRefs?.has(ref)); + // TODO(firewall-bypass): replace with real pageHostname + context.firewall + // once webActionTools is plumbed in Task 5 of the firewall-bypass plan. const assessment = assessFill({ field: metadata, source: userApproved ? "user-approved" : "agent", From bef6126ddf893c4c821a1bf9042e7f8b11624e41 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 13:53:48 -0400 Subject: [PATCH 28/44] feat(core): resolve submitter formaction override in getFormSubmissionContext --- .../core/src/browser/playwrightBrowser.ts | 20 ++++++++- packages/core/test/playwrightBrowser.test.ts | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/core/src/browser/playwrightBrowser.ts b/packages/core/src/browser/playwrightBrowser.ts index 6ba1d421..cb9ffa19 100644 --- a/packages/core/src/browser/playwrightBrowser.ts +++ b/packages/core/src/browser/playwrightBrowser.ts @@ -929,11 +929,29 @@ export class PlaywrightBrowser implements AriaBrowser { autocomplete: "autocomplete" in field ? field.autocomplete || null : null, })); + const submitterActionUrl = (() => { + if ( + !(el instanceof HTMLButtonElement) && + !(el instanceof HTMLInputElement) + ) + return null; + if ( + el instanceof HTMLInputElement && + el.type !== "submit" && + el.type !== "image" + ) + return null; + if (el instanceof HTMLButtonElement && el.type !== "submit") + return null; + if (!el.hasAttribute("formaction")) return null; + return el.formAction || null; + })(); + return { submitterRef, formId: form.id || null, actionUrl: form.action || null, - submitterActionUrl: null, + submitterActionUrl, method: form.method?.toLowerCase() || null, fields, }; diff --git a/packages/core/test/playwrightBrowser.test.ts b/packages/core/test/playwrightBrowser.test.ts index 0350577a..9850ce43 100644 --- a/packages/core/test/playwrightBrowser.test.ts +++ b/packages/core/test/playwrightBrowser.test.ts @@ -889,6 +889,51 @@ describe("PlaywrightBrowser", () => { "Failed to get form submission context: Execution context was destroyed", ); }); + + it("returns submitterActionUrl from the evaluate result", async () => { + const mockLocator = { + count: vi.fn().mockResolvedValue(1), + evaluate: vi.fn().mockResolvedValue({ + submitterRef: "btn", + formId: null, + actionUrl: "https://example.com/normal", + submitterActionUrl: "https://override.example.com/special", + method: "post", + fields: [], + }), + }; + const mockPage = { + locator: vi.fn().mockReturnValue(mockLocator), + }; + (browser as any).page = mockPage; + + const ctx = await browser.getFormSubmissionContext("btn", "click"); + expect(ctx).not.toBeNull(); + expect(ctx!.actionUrl).toBe("https://example.com/normal"); + expect(ctx!.submitterActionUrl).toBe("https://override.example.com/special"); + }); + + it("returns null submitterActionUrl when the evaluate result has none", async () => { + const mockLocator = { + count: vi.fn().mockResolvedValue(1), + evaluate: vi.fn().mockResolvedValue({ + submitterRef: "btn", + formId: null, + actionUrl: "https://example.com/normal", + submitterActionUrl: null, + method: "post", + fields: [], + }), + }; + const mockPage = { + locator: vi.fn().mockReturnValue(mockLocator), + }; + (browser as any).page = mockPage; + + const ctx = await browser.getFormSubmissionContext("btn", "click"); + expect(ctx).not.toBeNull(); + expect(ctx!.submitterActionUrl).toBeNull(); + }); }); }); From 857e11898c78df8719a363d970fb6d0e1126563b Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 13:55:02 -0400 Subject: [PATCH 29/44] feat(core): add FIREWALL_BLOCKED_NON_INTERACTIVE event type --- packages/core/src/events.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 31b2f205..5a57416b 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -52,6 +52,9 @@ export enum WebAgentEventType { // Interactive mode events INTERACTIVE_FORM_DATA_REQUEST = "interactive:form_data:request", INTERACTIVE_FORM_DATA_ERROR = "interactive:form_data:error", + + // Firewall events + FIREWALL_BLOCKED_NON_INTERACTIVE = "firewall:blocked_non_interactive", } /** @@ -363,6 +366,19 @@ export interface InteractiveFormDataErrorEventData extends WebAgentEventData { fieldErrors: Record; } +export type FirewallRemediation = + | { kind: "add-trusted-hostnames"; hostnames: string[]; description: string } + | { kind: "enable-interactive-mode"; description: string } + | { kind: "enable-unsafe-mode"; description: string }; + +export interface FirewallBlockedNonInteractiveEventData extends WebAgentEventData { + reason: string; + kind: "freeform-fill" | "form-submission"; + pageHostname: string | null; + formActionHostnames: string[]; + remediations: FirewallRemediation[]; +} + /** * Union type of all event data types */ @@ -405,6 +421,10 @@ export type WebAgentEvent = | { type: WebAgentEventType.INTERACTIVE_FORM_DATA_ERROR; data: InteractiveFormDataErrorEventData; + } + | { + type: WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE; + data: FirewallBlockedNonInteractiveEventData; }; // ============================================================================ From dbea8003a4201208361ef4ceaa0d957434316347 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:00:25 -0400 Subject: [PATCH 30/44] feat(core): plumb FirewallConfig and interactive flag into web action tools Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/tools/webActionTools.ts | 115 ++++++++++++-- .../core/test/tools/webActionTools.test.ts | 148 ++++++++++++++++++ 2 files changed, 249 insertions(+), 14 deletions(-) diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index e3b67c39..9d15bfc6 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -13,7 +13,16 @@ import { buildExtractionPrompt, TOOL_STRINGS } from "../prompts.js"; import type { ProviderConfig } from "../provider.js"; import { BrowserException } from "../errors.js"; import { generateTextWithRetry } from "../utils/retry.js"; -import { assessFill, assessFormSubmission } from "../security/actionFirewall.js"; +import { + assessFill, + assessFormSubmission, + extractHostname, + type FirewallConfig, +} from "../security/actionFirewall.js"; +import type { + FirewallBlockedNonInteractiveEventData, + FirewallRemediation, +} from "../events.js"; import { withSpan, SpanStatusCode, @@ -29,6 +38,8 @@ interface WebActionContext { approvedRefs?: ReadonlySet; agentFilledRefs: Set; operationalRefs: Set; + firewall: FirewallConfig; + interactive: boolean; } /** @@ -51,6 +62,54 @@ type ActionResult = { const EMPTY_APPROVED_REFS = new Set(); +function buildRemediations(blockedHostnames: string[]): FirewallRemediation[] { + const uniqueHosts = Array.from( + new Set(blockedHostnames.filter((h): h is string => Boolean(h))), + ); + return [ + { + kind: "add-trusted-hostnames", + hostnames: uniqueHosts, + description: + uniqueHosts.length > 0 + ? `Add ${uniqueHosts.join(", ")} to trusted_hostnames to allow this action on this site.` + : "Add the page hostname to trusted_hostnames to allow this action on this site.", + }, + { + kind: "enable-interactive-mode", + description: + "Run in interactive mode by providing a UserDataCallback so the agent can ask the user to approve sensitive fields per-action via request_user_data.", + }, + { + kind: "enable-unsafe-mode", + description: + "Set unsafe_mode=true to disable the action firewall entirely. WARNING: prompt injection from page content can then drive the agent to submit any field, including personal and credential data, to attacker-controlled forms.", + }, + ]; +} + +function emitNonInteractiveBlock( + context: WebActionContext, + kind: "freeform-fill" | "form-submission", + reason: string, + pageHostname: string | null, + formActionHostnames: string[], +): void { + if (context.interactive) return; + const hostsForRemediation = + pageHostname === null ? formActionHostnames : [pageHostname, ...formActionHostnames]; + const data: FirewallBlockedNonInteractiveEventData = { + timestamp: Date.now(), + iterationId: "", + reason, + kind, + pageHostname, + formActionHostnames, + remediations: buildRemediations(hostsForRemediation), + }; + context.eventEmitter.emit(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, data); +} + function failedActionResult( action: string, error: string, @@ -81,24 +140,37 @@ async function assessFormSubmissionForAction( ref: string, ): Promise { try { - const form = await context.browser.getFormSubmissionContext( - ref, - action === PageAction.Click ? "click" : "enter", - ); + const [form, pageUrl] = await Promise.all([ + context.browser.getFormSubmissionContext( + ref, + action === PageAction.Click ? "click" : "enter", + ), + context.browser.getUrl(), + ]); if (!form) return null; + const pageHostname = extractHostname(pageUrl); + const formActionHostnames = [ + extractHostname(form.actionUrl), + extractHostname(form.submitterActionUrl), + ].filter((h): h is string => h !== null); - // TODO(firewall-bypass): replace with real pageHostname + context.firewall - // once webActionTools is plumbed in Task 5 of the firewall-bypass plan. const assessment = assessFormSubmission({ form, approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, agentFilledRefs: context.agentFilledRefs, operationalRefs: context.operationalRefs, - pageHostname: null, - firewall: { trustedHostnames: new Set(), unsafeMode: false }, + pageHostname, + firewall: context.firewall, }); if (!assessment.allowed) { + emitNonInteractiveBlock( + context, + "form-submission", + assessment.reason, + pageHostname, + formActionHostnames, + ); return failedActionResult(action, assessment.reason, context, ref); } } catch (error) { @@ -208,6 +280,12 @@ export function createWebActionTools(context: WebActionContext) { if (!context.agentFilledRefs || !context.operationalRefs) { throw new Error("Web action provenance tracking sets are required"); } + if (!context.firewall) { + throw new Error("FirewallConfig is required on WebActionContext"); + } + if (typeof context.interactive !== "boolean") { + throw new Error("interactive flag is required on WebActionContext"); + } return { click: tool({ @@ -231,18 +309,27 @@ export function createWebActionTools(context: WebActionContext) { }), execute: async ({ ref, value }) => { try { - const metadata = await context.browser.getFieldMetadata(ref); + const [metadata, pageUrl] = await Promise.all([ + context.browser.getFieldMetadata(ref), + context.browser.getUrl(), + ]); + const pageHostname = extractHostname(pageUrl); const userApproved = Boolean(context.approvedRefs?.has(ref)); - // TODO(firewall-bypass): replace with real pageHostname + context.firewall - // once webActionTools is plumbed in Task 5 of the firewall-bypass plan. const assessment = assessFill({ field: metadata, source: userApproved ? "user-approved" : "agent", - pageHostname: null, - firewall: { trustedHostnames: new Set(), unsafeMode: false }, + pageHostname, + firewall: context.firewall, }); if (!assessment.allowed) { + emitNonInteractiveBlock( + context, + "freeform-fill", + assessment.reason, + pageHostname, + [], + ); return failedActionResult(PageAction.Fill, assessment.reason, context, ref); } diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index 993b4805..98675067 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -141,6 +141,8 @@ describe("Web Action Tools", () => { abortSignal: undefined, agentFilledRefs: new Set(), operationalRefs: new Set(), + firewall: { trustedHostnames: new Set(), unsafeMode: false }, + interactive: false, }; tools = createWebActionTools(context); @@ -1002,4 +1004,150 @@ describe("Web Action Tools", () => { expect(result.value).toBe(longText); }); }); + + describe("firewall bypass and remediation", () => { + it("trustedHostnames allows freeform fill on a trusted page", async () => { + mockBrowser.url = "https://example.com/page"; + mockBrowser.fieldMetadata.set("ref-1", { + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }); + const performSpy = vi.spyOn(mockBrowser, "performAction"); + const trustedContext = { + ...context, + firewall: { trustedHostnames: new Set(["example.com"]), unsafeMode: false }, + }; + const trustedTools: any = createWebActionTools(trustedContext); + + const result = await trustedTools.fill.execute({ ref: "ref-1", value: "hi" }); + expect(result.success).toBe(true); + expect(performSpy).toHaveBeenCalled(); + }); + + it("unsafeMode allows fill of any field on any page", async () => { + mockBrowser.url = "https://attacker.com/"; + mockBrowser.fieldMetadata.set("ref-1", { + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }); + const performSpy = vi.spyOn(mockBrowser, "performAction"); + const unsafeContext = { + ...context, + firewall: { trustedHostnames: new Set(), unsafeMode: true }, + }; + const unsafeTools: any = createWebActionTools(unsafeContext); + + const result = await unsafeTools.fill.execute({ ref: "ref-1", value: "hi" }); + expect(result.success).toBe(true); + expect(performSpy).toHaveBeenCalled(); + }); + + it("emits FIREWALL_BLOCKED_NON_INTERACTIVE on fill block when interactive=false", async () => { + mockBrowser.url = "https://untrusted.com/"; + mockBrowser.fieldMetadata.set("ref-1", { + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }); + const performSpy = vi.spyOn(mockBrowser, "performAction"); + const events: unknown[] = []; + eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => + events.push(data), + ); + + const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }); + expect(result.success).toBe(false); + expect(performSpy).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + const data = events[0] as { + kind: string; + pageHostname: string | null; + remediations: Array<{ kind: string }>; + }; + expect(data.kind).toBe("freeform-fill"); + expect(data.pageHostname).toBe("untrusted.com"); + expect(data.remediations.map((r) => r.kind).sort()).toEqual( + ["add-trusted-hostnames", "enable-interactive-mode", "enable-unsafe-mode"].sort(), + ); + }); + + it("does NOT emit FIREWALL_BLOCKED_NON_INTERACTIVE when interactive=true", async () => { + mockBrowser.url = "https://untrusted.com/"; + mockBrowser.fieldMetadata.set("ref-1", { + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }); + const events: unknown[] = []; + eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => + events.push(data), + ); + const interactiveContext = { ...context, interactive: true }; + const interactiveTools: any = createWebActionTools(interactiveContext); + + const result = await interactiveTools.fill.execute({ ref: "ref-1", value: "hi" }); + expect(result.success).toBe(false); + expect(events).toHaveLength(0); + }); + + it("model-visible error string does not include unsafe_mode or trusted_hostnames", async () => { + mockBrowser.url = "https://untrusted.com/"; + mockBrowser.fieldMetadata.set("ref-1", { + ref: "ref-1", + tagName: "textarea", + inputType: null, + role: null, + name: "comment", + label: "Comment", + placeholder: null, + autocomplete: null, + isContentEditable: false, + formId: null, + formAction: null, + formMethod: null, + }); + const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error).not.toMatch(/unsafe_mode|trusted_hostnames|untrusted\.com/); + }); + }); }); From 15a0ef2931af406a13b290013ac093691e6ea7a2 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:02:16 -0400 Subject: [PATCH 31/44] test(core): assert full event payload shape on firewall block --- packages/core/test/tools/webActionTools.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index 98675067..28fe6ade 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -1091,13 +1091,22 @@ describe("Web Action Tools", () => { const data = events[0] as { kind: string; pageHostname: string | null; - remediations: Array<{ kind: string }>; + formActionHostnames: string[]; + reason: string; + timestamp: number; + remediations: Array<{ kind: string; hostnames?: string[]; description: string }>; }; expect(data.kind).toBe("freeform-fill"); expect(data.pageHostname).toBe("untrusted.com"); + expect(data.formActionHostnames).toEqual([]); + expect(typeof data.reason).toBe("string"); + expect(data.reason.length).toBeGreaterThan(0); + expect(typeof data.timestamp).toBe("number"); expect(data.remediations.map((r) => r.kind).sort()).toEqual( ["add-trusted-hostnames", "enable-interactive-mode", "enable-unsafe-mode"].sort(), ); + const trusted = data.remediations.find((r) => r.kind === "add-trusted-hostnames"); + expect(trusted?.hostnames).toEqual(["untrusted.com"]); }); it("does NOT emit FIREWALL_BLOCKED_NON_INTERACTIVE when interactive=true", async () => { From fc959680c6f07ce40c294d0a42578d39dd4f7821 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:04:43 -0400 Subject: [PATCH 32/44] feat(core): add trustedHostnames and unsafeMode to WebAgentOptions Builds a frozen FirewallConfig at construction and passes firewall and interactive into createWebActionTools, fixing the typecheck failure from Task 5. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/webAgent.ts | 31 ++++++++++++++++++++++ packages/core/test/webAgent.test.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/core/src/webAgent.ts b/packages/core/src/webAgent.ts index f751d177..0273aa80 100644 --- a/packages/core/src/webAgent.ts +++ b/packages/core/src/webAgent.ts @@ -60,6 +60,10 @@ import { SpanName, recordSanitizedException, } from "./telemetry/tracing.js"; +import { + normalizeHostname, + type FirewallConfig, +} from "./security/actionFirewall.js"; // === Type Definitions === @@ -100,6 +104,26 @@ export interface WebAgentOptions { onUserDataRequired?: UserDataCallback; /** Correlation ID for this task, propagated to logs and traces. */ taskId?: string; + /** + * Hostnames where the action firewall is bypassed for fills and submissions. + * + * @warning On listed hosts, prompt injection from page content can drive the + * agent to fill and submit any field, including personal and credential data. + * Use only for sites you fully trust to receive your data. The bypass applies + * only when the current page hostname AND every form-action hostname (the + * form's `action` plus any submitter `formaction` override) are all in this + * list. + */ + trustedHostnames?: readonly string[]; + /** + * Disables the action firewall entirely. + * + * @warning When true, prompt injection from page content can cause the agent + * to submit your data, including credentials, personal information, and + * conversation context, to attacker-controlled forms. Only enable for + * trusted, controlled environments. + */ + unsafeMode?: boolean; } export interface ExecuteOptions { @@ -229,6 +253,7 @@ export class WebAgent { private readonly tabstackApiUrl: string | undefined; private readonly onUserDataRequired: UserDataCallback | undefined; private readonly taskId: string | undefined; + private readonly firewall: FirewallConfig; constructor( private browser: AriaBrowser, @@ -253,6 +278,10 @@ export class WebAgent { this.tabstackApiUrl = options.tabstackApiUrl; this.onUserDataRequired = options.onUserDataRequired; this.taskId = options.taskId; + this.firewall = Object.freeze({ + trustedHostnames: new Set((options.trustedHostnames ?? []).map((h) => normalizeHostname(h))), + unsafeMode: Boolean(options.unsafeMode), + }); if (this.searchProvider === "parallel-api" && !this.searchApiKey) { throw new Error("parallel_api_key is required when search_provider is 'parallel-api'"); @@ -412,6 +441,8 @@ export class WebAgent { approvedRefs: approvedRefs ?? undefined, agentFilledRefs, operationalRefs, + firewall: this.firewall, + interactive: Boolean(this.onUserDataRequired), }); // Only include search tools if a search service was created diff --git a/packages/core/test/webAgent.test.ts b/packages/core/test/webAgent.test.ts index 0bf0ec12..c7257ade 100644 --- a/packages/core/test/webAgent.test.ts +++ b/packages/core/test/webAgent.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { WebAgent, WebAgentOptions } from "../src/webAgent.js"; +import { InvalidHostnameError } from "../src/security/actionFirewall.js"; import { AriaBrowser, FieldMetadata, @@ -3974,3 +3975,42 @@ describe("WebAgent", () => { }); }); }); + +describe("WebAgent firewall options", () => { + let mockProvider: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockProvider = { specificationVersion: "v1" } as unknown as any; + }); + + it("throws InvalidHostnameError when trustedHostnames contains an invalid entry", () => { + const browser = new MockBrowser(); + expect(() => + new WebAgent(browser, { + providerConfig: { model: mockProvider }, + trustedHostnames: ["bad value"], + }), + ).toThrow(InvalidHostnameError); + }); + + it("normalizes trustedHostnames at construction", () => { + const browser = new MockBrowser(); + expect(() => + new WebAgent(browser, { + providerConfig: { model: mockProvider }, + trustedHostnames: ["Example.COM", "app.example.com."], + }), + ).not.toThrow(); + }); + + it("accepts unsafeMode true", () => { + const browser = new MockBrowser(); + expect(() => + new WebAgent(browser, { + providerConfig: { model: mockProvider }, + unsafeMode: true, + }), + ).not.toThrow(); + }); +}); From 588b1966076b348314925cb98544724be0096763 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:05:23 -0400 Subject: [PATCH 33/44] test(core): include firewall event in WebAgentEventType validation list --- packages/core/test/events.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/test/events.test.ts b/packages/core/test/events.test.ts index f8f1b271..bcd73117 100644 --- a/packages/core/test/events.test.ts +++ b/packages/core/test/events.test.ts @@ -141,6 +141,7 @@ describe("WebAgentEventEmitter", () => { "browser:reconnected", "interactive:form_data:request", "interactive:form_data:error", + "firewall:blocked_non_interactive", ]; const actualEventTypes = Object.values(WebAgentEventType); From 91140c385bdbd40326c69c7d73dec8392bf8bb6c Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:08:18 -0400 Subject: [PATCH 34/44] feat(core): add trusted_hostnames and unsafe_mode config fields --- packages/core/src/config/defaults.ts | 25 ++++++++++++++ packages/core/test/config.test.ts | 2 ++ packages/core/test/config/defaults.test.ts | 38 ++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 packages/core/test/config/defaults.test.ts diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index a38a1091..9a260261 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -112,6 +112,8 @@ export interface PiloConfig { // Action Configuration action_timeout_ms?: number; + trusted_hostnames?: string[]; + unsafe_mode?: boolean; // Search Configuration search_provider?: SearchProviderName; @@ -181,6 +183,8 @@ export interface PiloConfigResolved { // Action Configuration action_timeout_ms: number; + trusted_hostnames: string[]; + unsafe_mode: boolean; // Search Configuration search_provider: SearchProviderName; @@ -587,6 +591,25 @@ export const FIELDS: Record = { description: "Timeout for page load and element actions in milliseconds", category: "action", }, + trusted_hostnames: { + default: [], + type: "string[]", + cli: "--trusted-hostnames", + placeholder: "host1,host2,...", + env: ["PILO_TRUSTED_HOSTNAMES"], + description: + "Comma-separated hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, prompt injection from page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data.", + category: "action", + }, + unsafe_mode: { + default: false, + type: "boolean", + cli: "--unsafe", + env: ["PILO_UNSAFE_MODE"], + description: + "Disables the action firewall entirely. WARNING: prompt injection from page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments.", + category: "action", + }, // Search Configuration search_provider: { @@ -663,6 +686,8 @@ function buildDefaults(): PiloConfigResolved { "navigation_max_attempts", "navigation_timeout_multiplier", "action_timeout_ms", + "trusted_hostnames", + "unsafe_mode", "search_provider", ]; diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index cc7da5b1..e125fead 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -187,6 +187,8 @@ describe("ConfigManager", () => { "navigation_max_attempts", "navigation_timeout_multiplier", "action_timeout_ms", + "trusted_hostnames", + "unsafe_mode", "search_provider", "parallel_api_key", "tabstack_api_key", diff --git a/packages/core/test/config/defaults.test.ts b/packages/core/test/config/defaults.test.ts new file mode 100644 index 00000000..4f97795c --- /dev/null +++ b/packages/core/test/config/defaults.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { FIELDS, DEFAULTS } from "../../src/config/defaults.js"; + +describe("config defaults: firewall fields", () => { + it("declares trusted_hostnames as string[] with empty default", () => { + expect(FIELDS.trusted_hostnames).toBeDefined(); + expect(FIELDS.trusted_hostnames.type).toBe("string[]"); + expect(FIELDS.trusted_hostnames.category).toBe("action"); + expect(DEFAULTS.trusted_hostnames).toEqual([]); + }); + + it("declares unsafe_mode as boolean with false default", () => { + expect(FIELDS.unsafe_mode).toBeDefined(); + expect(FIELDS.unsafe_mode.type).toBe("boolean"); + expect(FIELDS.unsafe_mode.category).toBe("action"); + expect(DEFAULTS.unsafe_mode).toBe(false); + }); + + it("trusted_hostnames description warns about data risk", () => { + expect(FIELDS.trusted_hostnames.description).toMatch(/WARNING/); + expect(FIELDS.trusted_hostnames.description.toLowerCase()).toContain("trust"); + }); + + it("unsafe_mode description warns about data risk", () => { + expect(FIELDS.unsafe_mode.description).toMatch(/WARNING/); + expect(FIELDS.unsafe_mode.description.toLowerCase()).toContain("firewall"); + }); + + it("trusted_hostnames has a CLI flag and env var", () => { + expect(FIELDS.trusted_hostnames.cli).toBe("--trusted-hostnames"); + expect(FIELDS.trusted_hostnames.env).toContain("PILO_TRUSTED_HOSTNAMES"); + }); + + it("unsafe_mode has a CLI flag and env var", () => { + expect(FIELDS.unsafe_mode.cli).toBe("--unsafe"); + expect(FIELDS.unsafe_mode.env).toContain("PILO_UNSAFE_MODE"); + }); +}); From 9283dff8405ce43888a06e90ee548b2805fb8a1d Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:10:18 -0400 Subject: [PATCH 35/44] test(core): verify CLI flags and env vars for firewall config --- packages/core/test/config/commander.test.ts | 33 +++++++++++++++++ packages/core/test/config/env.test.ts | 39 +++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 packages/core/test/config/commander.test.ts create mode 100644 packages/core/test/config/env.test.ts diff --git a/packages/core/test/config/commander.test.ts b/packages/core/test/config/commander.test.ts new file mode 100644 index 00000000..ce6081a7 --- /dev/null +++ b/packages/core/test/config/commander.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { Command } from "commander"; +import { addConfigOptions } from "../../src/config/commander.js"; + +describe("CLI: firewall flags", () => { + it("parses --trusted-hostnames as comma-separated list", () => { + const cmd = new Command().exitOverride(); + addConfigOptions(cmd); + cmd.action(() => {}); + cmd.parse(["node", "test", "--trusted-hostnames", "a.com,b.com"]); + const opts = cmd.opts(); + expect(opts.trustedHostnames).toEqual(["a.com", "b.com"]); + }); + + it("parses --unsafe as boolean true", () => { + const cmd = new Command().exitOverride(); + addConfigOptions(cmd); + cmd.action(() => {}); + cmd.parse(["node", "test", "--unsafe"]); + const opts = cmd.opts(); + expect(opts.unsafe).toBe(true); + }); + + it("does not set firewall opts when flags omitted", () => { + const cmd = new Command().exitOverride(); + addConfigOptions(cmd); + cmd.action(() => {}); + cmd.parse(["node", "test"]); + const opts = cmd.opts(); + expect(opts.trustedHostnames).toBeUndefined(); + expect(opts.unsafe).toBeUndefined(); + }); +}); diff --git a/packages/core/test/config/env.test.ts b/packages/core/test/config/env.test.ts new file mode 100644 index 00000000..5e76ce2d --- /dev/null +++ b/packages/core/test/config/env.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { parseEnvConfig } from "../../src/config/env.js"; + +describe("env: firewall fields", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PILO_TRUSTED_HOSTNAMES; + delete process.env.PILO_UNSAFE_MODE; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("parses PILO_TRUSTED_HOSTNAMES as comma-separated list", () => { + process.env.PILO_TRUSTED_HOSTNAMES = "a.com,b.com"; + const result = parseEnvConfig(); + expect(result.trusted_hostnames).toEqual(["a.com", "b.com"]); + }); + + it("parses PILO_UNSAFE_MODE=true as boolean true", () => { + process.env.PILO_UNSAFE_MODE = "true"; + const result = parseEnvConfig(); + expect(result.unsafe_mode).toBe(true); + }); + + it("parses PILO_UNSAFE_MODE=false as boolean false", () => { + process.env.PILO_UNSAFE_MODE = "false"; + const result = parseEnvConfig(); + expect(result.unsafe_mode).toBe(false); + }); + + it("returns undefined when env vars are not set", () => { + const result = parseEnvConfig(); + expect(result.trusted_hostnames).toBeUndefined(); + expect(result.unsafe_mode).toBeUndefined(); + }); +}); From 81d11ad1eecf6954dc5c470a1bf4d0da9427015c Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:14:27 -0400 Subject: [PATCH 36/44] feat(cli): wire firewall config and print non-interactive remediation footer Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/run.ts | 54 +++++++++++++++- packages/cli/test/commands/run.test.ts | 88 +++++++++++++++++++++++++- packages/core/src/core.ts | 2 + 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index f5bbde3a..3619ac76 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -14,7 +14,13 @@ import { MetricsCollector, SecretsRedactor, } from "pilo-core"; -import type { Logger, UserDataCallback, UserDataRequest, UserDataResponse } from "pilo-core"; +import type { + Logger, + UserDataCallback, + UserDataRequest, + UserDataResponse, + FirewallBlockedNonInteractiveEventData, +} from "pilo-core"; import { validateBrowser, getValidBrowsers, parseJsonData, parseResourcesList } from "../utils.js"; import * as fs from "fs"; import * as path from "path"; @@ -279,6 +285,13 @@ async function executeRunCommand(task: string, options: any): Promise { }); } + eventEmitter.onEvent( + WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, + (data: unknown) => { + printFirewallRemediation(data as FirewallBlockedNonInteractiveEventData); + }, + ); + // Create WebAgent const webAgent = new WebAgent(browser, { debug: debugMode, @@ -294,6 +307,8 @@ async function executeRunCommand(task: string, options: any): Promise { searchApiKey: cfg.parallel_api_key, tabstackApiKey: options.tabstackApiKey ?? cfg.tabstack_api_key, tabstackApiUrl: options.tabstackApiUrl ?? cfg.tabstack_api_url, + trustedHostnames: options.trustedHostnames ?? cfg.trusted_hostnames, + unsafeMode: options.unsafe ?? cfg.unsafe_mode, providerConfig, logger, eventEmitter, @@ -313,3 +328,40 @@ async function executeRunCommand(task: string, options: any): Promise { process.exit(1); } } + +export function printFirewallRemediation(data: FirewallBlockedNonInteractiveEventData): void { + const lines: string[] = []; + lines.push(""); + lines.push(chalk.yellow.bold("Pilo: an action was blocked by the prompt-injection firewall.")); + lines.push(chalk.yellow(`Reason: ${data.reason}`)); + + const involvedHosts = Array.from( + new Set( + [data.pageHostname, ...data.formActionHostnames].filter((h): h is string => Boolean(h)), + ), + ); + if (involvedHosts.length > 0) { + lines.push(chalk.yellow(`Hostnames involved: ${involvedHosts.join(", ")}`)); + } + + lines.push(chalk.yellow("To allow this action, you can:")); + for (const r of data.remediations) { + if (r.kind === "add-trusted-hostnames") { + const cmd = + r.hostnames.length > 0 + ? `pilo config set trusted_hostnames ${r.hostnames.join(",")}` + : "pilo config set trusted_hostnames "; + lines.push(` - ${r.description}`); + lines.push(` Run: ${chalk.cyan(cmd)}`); + } else if (r.kind === "enable-interactive-mode") { + lines.push(` - ${r.description}`); + } else if (r.kind === "enable-unsafe-mode") { + lines.push(` - ${r.description}`); + lines.push(` Run: ${chalk.cyan("pilo config set unsafe_mode true")}`); + } + } + + for (const line of lines) { + console.warn(line); + } +} diff --git a/packages/cli/test/commands/run.test.ts b/packages/cli/test/commands/run.test.ts index 717f2737..4f87ee91 100644 --- a/packages/cli/test/commands/run.test.ts +++ b/packages/cli/test/commands/run.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Command } from "commander"; -import { createRunCommand } from "../../src/commands/run.js"; +import { createRunCommand, printFirewallRemediation } from "../../src/commands/run.js"; +import type { FirewallBlockedNonInteractiveEventData } from "pilo-core"; import { getConfigDefaults } from "pilo-core"; // Get defaults from schema (used for mocking config.getConfig) @@ -37,6 +38,7 @@ vi.mock("pilo-core", async (importOriginal) => { }), WebAgentEventType: { AI_GENERATION: "ai:generation", + FIREWALL_BLOCKED_NON_INTERACTIVE: "firewall:blocked_non_interactive", }, WebAgentEventEmitter: vi.fn().mockImplementation(function () { return { @@ -587,3 +589,87 @@ describe("CLI Run Command", () => { }); }); }); + +describe("printFirewallRemediation", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("prints all three remediation options with the blocked hostname", () => { + const data: FirewallBlockedNonInteractiveEventData = { + timestamp: Date.now(), + iterationId: "", + reason: "Security policy blocked submitting a form containing unauthorized agent-filled data", + kind: "form-submission", + pageHostname: "untrusted.com", + formActionHostnames: ["untrusted.com"], + remediations: [ + { + kind: "add-trusted-hostnames", + hostnames: ["untrusted.com"], + description: + "Add untrusted.com to trusted_hostnames to allow this action on this site.", + }, + { + kind: "enable-interactive-mode", + description: + "Run in interactive mode by providing a UserDataCallback so the agent can ask the user to approve sensitive fields per-action via request_user_data.", + }, + { + kind: "enable-unsafe-mode", + description: "Set unsafe_mode=true to disable the action firewall entirely. WARNING: ...", + }, + ], + }; + + printFirewallRemediation(data); + const output = warnSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join("\n") + .replace(/\x1b\[[0-9;]*m/g, ""); + expect(output).toContain("untrusted.com"); + expect(output).toContain("trusted_hostnames untrusted.com"); + expect(output).toContain("interactive mode"); + expect(output).toContain("unsafe_mode true"); + }); + + it("falls back to a generic command when no hostnames are listed", () => { + const data: FirewallBlockedNonInteractiveEventData = { + timestamp: Date.now(), + iterationId: "", + reason: "Security policy blocked filling a submittable form field without user approval", + kind: "freeform-fill", + pageHostname: null, + formActionHostnames: [], + remediations: [ + { + kind: "add-trusted-hostnames", + hostnames: [], + description: + "Add the page hostname to trusted_hostnames to allow this action on this site.", + }, + { + kind: "enable-interactive-mode", + description: "Run in interactive mode...", + }, + { + kind: "enable-unsafe-mode", + description: "Set unsafe_mode=true...", + }, + ], + }; + + printFirewallRemediation(data); + const output = warnSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join("\n") + .replace(/\x1b\[[0-9;]*m/g, ""); + expect(output).toContain("trusted_hostnames "); + }); +}); diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index a63d4b25..2244fced 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -40,6 +40,8 @@ export type { ValidationErrorEventData, InteractiveFormDataRequestEventData, InteractiveFormDataErrorEventData, + FirewallBlockedNonInteractiveEventData, + FirewallRemediation, AutomateStreamEvent, StreamCompleteEventData, StreamDoneEventData, From 00356f863775e6d3540bab43357245550c21f8ee Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:16:04 -0400 Subject: [PATCH 37/44] docs: document action firewall and bypass controls --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index e0efb303..17819399 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,42 @@ try { - 📝 **Rich Context**: Pass structured data to help with form filling and complex tasks - ☁️ **Tabstack API Integration**: Extract markdown, structured JSON, or AI-transformed data from any URL using [Tabstack](https://tabstack.ai) cloud tools — especially useful for PDFs which browsers cannot read directly +## Security Model + +Pilo treats every web page as untrusted input. By default, an **action firewall** prevents the agent from filling freeform form fields (textareas, contact-info inputs, password fields, etc.) and from submitting any form containing agent-filled values that the user did not explicitly approve. This is the structural defense against prompt-injection attacks where a page tries to coax the agent into exfiltrating data through a form. + +Two caller-supplied controls relax this protection. Both are off by default. **Enabling either weakens the firewall's data-protection guarantees.** + +### `trusted_hostnames` + +A list of hostnames on which the firewall is bypassed for fills and submissions. The bypass applies only when the current page hostname **and every form-action hostname** (the form's `action` plus any submitter `formaction` override) are all in the list. + +```bash +pilo config set trusted_hostnames example.com,app.example.com +``` + +WARNING: on listed hosts, prompt injection from page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. + +### `unsafe_mode` + +A global firewall disable. When enabled, neither the fill gate nor the submit gate applies, regardless of page or form-action hostname. + +```bash +pilo config set unsafe_mode true +``` + +WARNING: prompt injection from page content can then cause the agent to submit your data, including credentials, personal information, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. + +### Remediation when a block fires + +When the firewall blocks a fill or submission and the agent is not running in interactive mode (no `UserDataCallback`), the CLI prints a footer listing the three ways the user can enable the workflow: + +- Add the involved hostnames to `trusted_hostnames`. +- Run in interactive mode so the agent can request per-field approval through `request_user_data`. +- Enable `unsafe_mode` (with the data-protection warning above). + +The footer is shown only to the user; the model that drives the agent never sees these remediation suggestions, so prompt-injected page content cannot ask the user to enable the bypasses. + ## Configuration Pilo supports multiple AI providers and stores configuration globally at `~/.config/pilo/config.json` (XDG standard; `%APPDATA%/pilo/config.json` on Windows). From 5a65839fed1bbc592f366c2dc253c843882b9b45 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:16:25 -0400 Subject: [PATCH 38/44] chore: prettier pass after firewall bypass work --- .../2026-05-28-firewall-bypass-controls.md | 83 ++++++++++--------- ...6-05-28-firewall-bypass-controls-design.md | 12 +-- packages/cli/src/commands/run.ts | 9 +- packages/cli/test/commands/run.test.ts | 3 +- .../core/src/browser/playwrightBrowser.ts | 14 +--- packages/core/src/security/actionFirewall.ts | 5 +- packages/core/src/tools/webActionTools.ts | 17 +--- packages/core/src/webAgent.ts | 5 +- packages/core/test/webAgent.test.ts | 33 ++++---- 9 files changed, 79 insertions(+), 102 deletions(-) diff --git a/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md b/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md index cfa891a3..d5562353 100644 --- a/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md +++ b/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md @@ -14,30 +14,31 @@ ## File Structure -| Action | Path | Responsibility | -|---|---|---| -| Modify | `packages/core/src/security/actionFirewall.ts` | `FirewallConfig` type, `normalizeHostname`, `extractHostname`, `InvalidHostnameError`, bypass branches in `assessFill`/`assessFormSubmission` | -| Modify | `packages/core/src/browser/ariaBrowser.ts` | Add `submitterActionUrl` to `FormSubmissionContext` | -| Modify | `packages/core/src/browser/playwrightBrowser.ts` | Resolve and return `submitterActionUrl` | -| Modify | `packages/core/src/events.ts` | Add `FIREWALL_BLOCKED_NON_INTERACTIVE` event type + data type | -| Modify | `packages/core/src/tools/webActionTools.ts` | Extend `WebActionContext` with `firewall` and `interactive`; query page hostname; pass to firewall; emit non-interactive event on block | -| Modify | `packages/core/src/webAgent.ts` | Add `trustedHostnames` / `unsafeMode` options; build frozen `FirewallConfig`; thread `interactive` into tool context | -| Modify | `packages/core/src/config/defaults.ts` | New `trusted_hostnames` (string[]) and `unsafe_mode` (boolean) fields with warning descriptions | -| Modify | `packages/core/src/config/commander.ts` | (No code change expected — `addConfigOptions` already handles `string[]` and `boolean` types automatically) | -| Modify | `packages/core/src/config/env.ts` | (No code change expected — generic env coercion handles both new fields) | -| Modify | `packages/cli/src/commands/run.ts` | Pass `trustedHostnames` / `unsafeMode` from merged config into `WebAgent`; subscribe to `FIREWALL_BLOCKED_NON_INTERACTIVE` and print remediation footer | -| Modify | `packages/core/src/index.ts` and `packages/core/src/core.ts` | Re-export `InvalidHostnameError` if it needs to be caught by callers | -| Create | `packages/core/test/security/actionFirewall.test.ts` | Add pure tests for normalization + bypass logic (file already exists per prior plan; new test cases appended) | -| Modify | `packages/core/test/tools/webActionTools.test.ts` | Tool-level tests for bypass and remediation event | -| Modify | `packages/core/test/playwrightBrowser.test.ts` | Test that `submitterActionUrl` is resolved and returned | -| Modify | `packages/core/test/webAgent.test.ts` | Integration tests for option plumbing and end-to-end bypass behavior | -| Modify | `README.md` (root) | Add "Security model" subsection | +| Action | Path | Responsibility | +| ------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Modify | `packages/core/src/security/actionFirewall.ts` | `FirewallConfig` type, `normalizeHostname`, `extractHostname`, `InvalidHostnameError`, bypass branches in `assessFill`/`assessFormSubmission` | +| Modify | `packages/core/src/browser/ariaBrowser.ts` | Add `submitterActionUrl` to `FormSubmissionContext` | +| Modify | `packages/core/src/browser/playwrightBrowser.ts` | Resolve and return `submitterActionUrl` | +| Modify | `packages/core/src/events.ts` | Add `FIREWALL_BLOCKED_NON_INTERACTIVE` event type + data type | +| Modify | `packages/core/src/tools/webActionTools.ts` | Extend `WebActionContext` with `firewall` and `interactive`; query page hostname; pass to firewall; emit non-interactive event on block | +| Modify | `packages/core/src/webAgent.ts` | Add `trustedHostnames` / `unsafeMode` options; build frozen `FirewallConfig`; thread `interactive` into tool context | +| Modify | `packages/core/src/config/defaults.ts` | New `trusted_hostnames` (string[]) and `unsafe_mode` (boolean) fields with warning descriptions | +| Modify | `packages/core/src/config/commander.ts` | (No code change expected — `addConfigOptions` already handles `string[]` and `boolean` types automatically) | +| Modify | `packages/core/src/config/env.ts` | (No code change expected — generic env coercion handles both new fields) | +| Modify | `packages/cli/src/commands/run.ts` | Pass `trustedHostnames` / `unsafeMode` from merged config into `WebAgent`; subscribe to `FIREWALL_BLOCKED_NON_INTERACTIVE` and print remediation footer | +| Modify | `packages/core/src/index.ts` and `packages/core/src/core.ts` | Re-export `InvalidHostnameError` if it needs to be caught by callers | +| Create | `packages/core/test/security/actionFirewall.test.ts` | Add pure tests for normalization + bypass logic (file already exists per prior plan; new test cases appended) | +| Modify | `packages/core/test/tools/webActionTools.test.ts` | Tool-level tests for bypass and remediation event | +| Modify | `packages/core/test/playwrightBrowser.test.ts` | Test that `submitterActionUrl` is resolved and returned | +| Modify | `packages/core/test/webAgent.test.ts` | Integration tests for option plumbing and end-to-end bypass behavior | +| Modify | `README.md` (root) | Add "Security model" subsection | --- ## Task 1: Hostname normalization and extraction helpers **Files:** + - Modify: `packages/core/src/security/actionFirewall.ts` - Test: `packages/core/test/security/actionFirewall.test.ts` @@ -237,6 +238,7 @@ git commit -m "feat(core): add hostname normalization and extraction helpers" ## Task 2: FirewallConfig type and bypass branches **Files:** + - Modify: `packages/core/src/security/actionFirewall.ts` - Modify: `packages/core/test/security/actionFirewall.test.ts` - Modify (consumer): `packages/core/src/tools/webActionTools.ts` (compile-fix only) @@ -251,10 +253,7 @@ import { assessFormSubmission, type FirewallConfig, } from "../../src/security/actionFirewall.js"; -import type { - FieldMetadata, - FormSubmissionContext, -} from "../../src/browser/ariaBrowser.js"; +import type { FieldMetadata, FormSubmissionContext } from "../../src/browser/ariaBrowser.js"; const freeformField: FieldMetadata = { ref: "ref-1", @@ -594,10 +593,7 @@ export function assessFill(input: { if (input.firewall.unsafeMode) { return { allowed: true }; } - if ( - input.pageHostname !== null && - input.firewall.trustedHostnames.has(input.pageHostname) - ) { + if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { return { allowed: true }; } @@ -774,6 +770,7 @@ git commit -m "feat(core): add FirewallConfig and bypass branches to action fire ## Task 3: Add `submitterActionUrl` to FormSubmissionContext **Files:** + - Modify: `packages/core/src/browser/ariaBrowser.ts` - Modify: `packages/core/src/browser/playwrightBrowser.ts` - Modify: `packages/core/test/playwrightBrowser.test.ts` @@ -880,6 +877,7 @@ git commit -m "feat(core): expose submitter formaction override on FormSubmissio ## Task 4: Add `FIREWALL_BLOCKED_NON_INTERACTIVE` event type **Files:** + - Modify: `packages/core/src/events.ts` - [ ] **Step 1: Add the event type enum value** @@ -940,6 +938,7 @@ git commit -m "feat(core): add FIREWALL_BLOCKED_NON_INTERACTIVE event type" ## Task 5: Plumb `firewall` and `interactive` into webActionTools and emit the event **Files:** + - Modify: `packages/core/src/tools/webActionTools.ts` - Modify: `packages/core/test/tools/webActionTools.test.ts` @@ -1052,7 +1051,9 @@ describe("webActionTools firewall bypass and remediation", () => { }); const eventEmitter = new WebAgentEventEmitter(); const events: unknown[] = []; - eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => events.push(data)); + eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => + events.push(data), + ); const tools = createWebActionTools({ browser, @@ -1101,7 +1102,9 @@ describe("webActionTools firewall bypass and remediation", () => { }); const eventEmitter = new WebAgentEventEmitter(); const events: unknown[] = []; - eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => events.push(data)); + eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => + events.push(data), + ); const tools = createWebActionTools({ browser, @@ -1175,10 +1178,7 @@ import { extractHostname, type FirewallConfig, } from "../security/actionFirewall.js"; -import type { - FirewallBlockedNonInteractiveEventData, - FirewallRemediation, -} from "../events.js"; +import type { FirewallBlockedNonInteractiveEventData, FirewallRemediation } from "../events.js"; ``` Replace the existing import line that brought in `assessFill, assessFormSubmission` with this combined import. @@ -1383,6 +1383,7 @@ git commit -m "feat(core): plumb FirewallConfig and interactive flag into web ac ## Task 6: WebAgent option additions and FirewallConfig construction **Files:** + - Modify: `packages/core/src/webAgent.ts` - Modify: `packages/core/test/webAgent.test.ts` @@ -1411,7 +1412,6 @@ describe("WebAgent firewall options", () => { it("interactive flag is set from onUserDataRequired presence", async () => { // Setup an agent without onUserDataRequired. Trigger a firewall-blocked fill. // Assert: FIREWALL_BLOCKED_NON_INTERACTIVE is emitted. - // Setup another agent with a stub onUserDataRequired. Trigger a firewall-blocked fill. // Assert: FIREWALL_BLOCKED_NON_INTERACTIVE is NOT emitted. }); @@ -1436,10 +1436,7 @@ In `packages/core/src/webAgent.ts`: 3a. Add imports (top of file): ```ts -import { - normalizeHostname, - type FirewallConfig, -} from "./security/actionFirewall.js"; +import { normalizeHostname, type FirewallConfig } from "./security/actionFirewall.js"; ``` 3b. Extend `WebAgentOptions` (around line 66). Add two new fields with TSDoc warnings: @@ -1529,6 +1526,7 @@ git commit -m "feat(core): add trustedHostnames and unsafeMode to WebAgentOption ## Task 7: Config defaults — `trusted_hostnames` and `unsafe_mode` **Files:** + - Modify: `packages/core/src/config/defaults.ts` - Modify: `packages/core/test/config/*.test.ts` (add tests next to existing config tests) @@ -1641,6 +1639,7 @@ git commit -m "feat(core): add trusted_hostnames and unsafe_mode config fields" ## Task 8: CLI + env wiring (verify no code changes needed) **Files:** + - Verify: `packages/core/src/config/commander.ts` - Verify: `packages/core/src/config/env.ts` - Modify: `packages/core/test/config/` (add CLI + env tests if not present) @@ -1738,6 +1737,7 @@ git commit -m "test(core): verify CLI flags and env vars for firewall config" ## Task 9: CLI consumer — pass config to WebAgent and print remediation footer **Files:** + - Modify: `packages/cli/src/commands/run.ts` - Test: `packages/cli/test/commands/run.test.ts` (if test pattern exists; otherwise add a minimal unit test for the footer printer) @@ -1890,6 +1890,7 @@ git commit -m "feat(cli): wire firewall config and print non-interactive remedia ## Task 10: Documentation — TSDoc and README **Files:** + - Modify: `README.md` (root) - Verify TSDoc already added in Task 6 (`packages/core/src/webAgent.ts`) @@ -1899,7 +1900,7 @@ Locate the existing top-level sections in `README.md`. Add a new subsection — Append this subsection (adapt heading level to match the surrounding doc): -```markdown +````markdown ## Security model Pilo treats every web page as untrusted input. By default, the **action firewall** prevents the agent from filling freeform form fields (textareas, contact-info inputs, password fields, etc.) and from submitting any form containing agent-filled values that the user did not explicitly approve. This is the structural defense against prompt-injection attacks where a page tries to coax the agent into exfiltrating data via a form. @@ -1913,6 +1914,7 @@ A list of hostnames on which the firewall is bypassed for fills and submissions. ```bash pilo config set trusted_hostnames example.com,app.example.com ``` +```` WARNING: on listed hosts, prompt injection from page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. @@ -1935,7 +1937,8 @@ When the firewall blocks a fill or submission and the agent is not running in in - Enable `unsafe_mode` (with the data-protection warning above). The footer is shown only to the user; the model that drives the agent never sees these remediation suggestions, so prompt-injected page content cannot ask the user to enable the bypasses. -``` + +```` - [ ] **Step 2: Verify TSDoc was added in Task 6** @@ -1950,7 +1953,7 @@ If the TSDoc is missing or incomplete, add it now (copy from Task 6 Step 3b). ```bash git add README.md packages/core/src/webAgent.ts git commit -m "docs: document action firewall and bypass controls" -``` +```` --- diff --git a/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md b/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md index d030c832..3d63bd71 100644 --- a/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md +++ b/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md @@ -173,10 +173,10 @@ At task start, WebAgent constructs a frozen `FirewallConfig`: Two new fields in the `action` category: -| Key | Type | Default | Description (short form) | -|---|---|---|---| -| `trusted_hostnames` | `string[]` | `[]` | Hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. | -| `unsafe_mode` | `boolean` | `false` | Disables the action firewall entirely. WARNING: web page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. | +| Key | Type | Default | Description (short form) | +| ------------------- | ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `trusted_hostnames` | `string[]` | `[]` | Hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. | +| `unsafe_mode` | `boolean` | `false` | Disables the action firewall entirely. WARNING: web page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. | The field parser for `trusted_hostnames` applies `normalizeHostname` to each entry. A bad entry surfaces at config load (during `pilo config set`, `pilo config show`, or `pilo run` startup), naming the invalid value. @@ -214,10 +214,10 @@ A `FIREWALL_BLOCKED_NON_INTERACTIVE` event is emitted on the `WebAgentEventEmitt ```ts interface FirewallBlockedNonInteractiveEventData { - reason: string; // policy reason (no field values) + reason: string; // policy reason (no field values) kind: "freeform-fill" | "form-submission"; pageHostname: string | null; - formActionHostnames: string[]; // empty for fills + formActionHostnames: string[]; // empty for fills remediations: FirewallRemediation[]; } diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 3619ac76..97a505bc 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -285,12 +285,9 @@ async function executeRunCommand(task: string, options: any): Promise { }); } - eventEmitter.onEvent( - WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, - (data: unknown) => { - printFirewallRemediation(data as FirewallBlockedNonInteractiveEventData); - }, - ); + eventEmitter.onEvent(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data: unknown) => { + printFirewallRemediation(data as FirewallBlockedNonInteractiveEventData); + }); // Create WebAgent const webAgent = new WebAgent(browser, { diff --git a/packages/cli/test/commands/run.test.ts b/packages/cli/test/commands/run.test.ts index 4f87ee91..cc157ef3 100644 --- a/packages/cli/test/commands/run.test.ts +++ b/packages/cli/test/commands/run.test.ts @@ -613,8 +613,7 @@ describe("printFirewallRemediation", () => { { kind: "add-trusted-hostnames", hostnames: ["untrusted.com"], - description: - "Add untrusted.com to trusted_hostnames to allow this action on this site.", + description: "Add untrusted.com to trusted_hostnames to allow this action on this site.", }, { kind: "enable-interactive-mode", diff --git a/packages/core/src/browser/playwrightBrowser.ts b/packages/core/src/browser/playwrightBrowser.ts index cb9ffa19..685b98e8 100644 --- a/packages/core/src/browser/playwrightBrowser.ts +++ b/packages/core/src/browser/playwrightBrowser.ts @@ -930,19 +930,11 @@ export class PlaywrightBrowser implements AriaBrowser { })); const submitterActionUrl = (() => { - if ( - !(el instanceof HTMLButtonElement) && - !(el instanceof HTMLInputElement) - ) - return null; - if ( - el instanceof HTMLInputElement && - el.type !== "submit" && - el.type !== "image" - ) + if (!(el instanceof HTMLButtonElement) && !(el instanceof HTMLInputElement)) return null; - if (el instanceof HTMLButtonElement && el.type !== "submit") + if (el instanceof HTMLInputElement && el.type !== "submit" && el.type !== "image") return null; + if (el instanceof HTMLButtonElement && el.type !== "submit") return null; if (!el.hasAttribute("formaction")) return null; return el.formAction || null; })(); diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts index 90fa51cb..e05387f5 100644 --- a/packages/core/src/security/actionFirewall.ts +++ b/packages/core/src/security/actionFirewall.ts @@ -97,10 +97,7 @@ export function assessFill(input: { return { allowed: true }; } - if ( - input.pageHostname !== null && - input.firewall.trustedHostnames.has(input.pageHostname) - ) { + if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { return { allowed: true }; } diff --git a/packages/core/src/tools/webActionTools.ts b/packages/core/src/tools/webActionTools.ts index 9d15bfc6..e884c9ea 100644 --- a/packages/core/src/tools/webActionTools.ts +++ b/packages/core/src/tools/webActionTools.ts @@ -19,10 +19,7 @@ import { extractHostname, type FirewallConfig, } from "../security/actionFirewall.js"; -import type { - FirewallBlockedNonInteractiveEventData, - FirewallRemediation, -} from "../events.js"; +import type { FirewallBlockedNonInteractiveEventData, FirewallRemediation } from "../events.js"; import { withSpan, SpanStatusCode, @@ -63,9 +60,7 @@ type ActionResult = { const EMPTY_APPROVED_REFS = new Set(); function buildRemediations(blockedHostnames: string[]): FirewallRemediation[] { - const uniqueHosts = Array.from( - new Set(blockedHostnames.filter((h): h is string => Boolean(h))), - ); + const uniqueHosts = Array.from(new Set(blockedHostnames.filter((h): h is string => Boolean(h)))); return [ { kind: "add-trusted-hostnames", @@ -323,13 +318,7 @@ export function createWebActionTools(context: WebActionContext) { }); if (!assessment.allowed) { - emitNonInteractiveBlock( - context, - "freeform-fill", - assessment.reason, - pageHostname, - [], - ); + emitNonInteractiveBlock(context, "freeform-fill", assessment.reason, pageHostname, []); return failedActionResult(PageAction.Fill, assessment.reason, context, ref); } diff --git a/packages/core/src/webAgent.ts b/packages/core/src/webAgent.ts index 0273aa80..9b8bcdc7 100644 --- a/packages/core/src/webAgent.ts +++ b/packages/core/src/webAgent.ts @@ -60,10 +60,7 @@ import { SpanName, recordSanitizedException, } from "./telemetry/tracing.js"; -import { - normalizeHostname, - type FirewallConfig, -} from "./security/actionFirewall.js"; +import { normalizeHostname, type FirewallConfig } from "./security/actionFirewall.js"; // === Type Definitions === diff --git a/packages/core/test/webAgent.test.ts b/packages/core/test/webAgent.test.ts index c7257ade..7b579cae 100644 --- a/packages/core/test/webAgent.test.ts +++ b/packages/core/test/webAgent.test.ts @@ -3986,31 +3986,34 @@ describe("WebAgent firewall options", () => { it("throws InvalidHostnameError when trustedHostnames contains an invalid entry", () => { const browser = new MockBrowser(); - expect(() => - new WebAgent(browser, { - providerConfig: { model: mockProvider }, - trustedHostnames: ["bad value"], - }), + expect( + () => + new WebAgent(browser, { + providerConfig: { model: mockProvider }, + trustedHostnames: ["bad value"], + }), ).toThrow(InvalidHostnameError); }); it("normalizes trustedHostnames at construction", () => { const browser = new MockBrowser(); - expect(() => - new WebAgent(browser, { - providerConfig: { model: mockProvider }, - trustedHostnames: ["Example.COM", "app.example.com."], - }), + expect( + () => + new WebAgent(browser, { + providerConfig: { model: mockProvider }, + trustedHostnames: ["Example.COM", "app.example.com."], + }), ).not.toThrow(); }); it("accepts unsafeMode true", () => { const browser = new MockBrowser(); - expect(() => - new WebAgent(browser, { - providerConfig: { model: mockProvider }, - unsafeMode: true, - }), + expect( + () => + new WebAgent(browser, { + providerConfig: { model: mockProvider }, + unsafeMode: true, + }), ).not.toThrow(); }); }); From 4839ed5e8c7b48986ba758b2ab9cec732de558e7 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:21:35 -0400 Subject: [PATCH 39/44] docs: clarify firewall hostname validation timing in spec --- .../specs/2026-05-28-firewall-bypass-controls-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md b/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md index 3d63bd71..20541d29 100644 --- a/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md +++ b/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md @@ -178,7 +178,7 @@ Two new fields in the `action` category: | `trusted_hostnames` | `string[]` | `[]` | Hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. | | `unsafe_mode` | `boolean` | `false` | Disables the action firewall entirely. WARNING: web page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. | -The field parser for `trusted_hostnames` applies `normalizeHostname` to each entry. A bad entry surfaces at config load (during `pilo config set`, `pilo config show`, or `pilo run` startup), naming the invalid value. +Hostname validation runs at `WebAgent` construction (synchronously, before any agent iteration). A bad entry surfaces at `pilo run` startup, naming the invalid value. (The CLI's generic `pilo config set` path does not currently parse `string[]` values into arrays — a pre-existing limitation shared by other array-typed config fields like `pw_cdp_endpoints` — so per-set validation is not added here.) ### `packages/core/src/config/commander.ts` — extended @@ -273,7 +273,7 @@ Documentation is the compensating control for the silent-observability decision ## Error handling -- **Invalid hostname at config load** — `normalizeHostname` throws `InvalidHostnameError` with a message naming the bad entry. CLI surfaces the message and exits non-zero before the agent runs. +- **Invalid hostname at agent construction** — `normalizeHostname` throws `InvalidHostnameError` with a message naming the bad entry. CLI surfaces the message and exits non-zero before the agent runs. - **`browser.getUrl()` failure** — treated as `pageHostname = null`. Bypass cannot apply; existing structural rules run as today. - **Form action URL resolution failure** — treated as `null` hostname for that action. Bypass cannot apply for that submission; falls through to structural rules. - **Both `unsafeMode` and `trustedHostnames` set** — `unsafeMode` short-circuits first; `trustedHostnames` is moot. From e842809d74313afea507a888cbcb1d38e665637c Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:25:52 -0400 Subject: [PATCH 40/44] fix(cli): parse string[] config values and validate trusted_hostnames at set time parseConfigValue now CSV-splits when given a known string[] key (trusted_hostnames, pw_cdp_endpoints), so `pilo config set trusted_hostnames a.com,b.com` persists the array form instead of a literal string that would later crash WebAgent. setConfigurationValue also normalizes each entry of trusted_hostnames via normalizeHostname before persisting, so a bad host fails at config set time rather than at the next agent run. --- packages/cli/src/commands/config.ts | 10 +++++++--- packages/cli/src/utils.ts | 11 ++++++++++- packages/cli/test/commands/config.test.ts | 20 ++++++++++++++++++++ packages/cli/test/utils.test.ts | 19 +++++++++++++++++++ packages/core/src/core.ts | 3 +++ 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 651786cd..76c2973b 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,7 +1,7 @@ import chalk from "chalk"; import { Command } from "commander"; import { existsSync } from "fs"; -import { config, getAIProviderInfo } from "pilo-core"; +import { config, getAIProviderInfo, normalizeHostname } from "pilo-core"; import { getPackageInfo, parseConfigValue } from "../utils.js"; /** @@ -180,9 +180,13 @@ function getConfigurationValue(key: string): void { */ function setConfigurationValue(key: string, value: string): void { try { - const parsedValue = parseConfigValue(value); + let parsedValue = parseConfigValue(value, key as any); + if (key === "trusted_hostnames" && Array.isArray(parsedValue)) { + parsedValue = parsedValue.map((h: string) => normalizeHostname(h)); + } config.set(key as any, parsedValue); - console.log(chalk.green(`✅ Set ${key} = ${value}`)); + const displayValue = Array.isArray(parsedValue) ? parsedValue.join(",") : value; + console.log(chalk.green(`✅ Set ${key} = ${displayValue}`)); } catch (error) { console.error(chalk.red("❌ Error:"), error instanceof Error ? error.message : String(error)); console.log(chalk.gray("Example: pilo config set browser chrome")); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 3073728e..c13e5ab4 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; +import { FIELDS, type PiloConfig } from "pilo-core"; /** * CLI-specific utilities and helpers @@ -101,7 +102,15 @@ export function parseConfigKeyValue(keyValue: string): { key: string; value: str /** * Parse configuration value to appropriate type */ -export function parseConfigValue(value: string): any { +export function parseConfigValue(value: string, key?: keyof PiloConfig): any { + // When the field type is known and is string[], CSV-split the value. + if (key && FIELDS[key]?.type === "string[]") { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + // Parse boolean values if (value === "true") return true; if (value === "false") return false; diff --git a/packages/cli/test/commands/config.test.ts b/packages/cli/test/commands/config.test.ts index 6fe410c3..912a5dd0 100644 --- a/packages/cli/test/commands/config.test.ts +++ b/packages/cli/test/commands/config.test.ts @@ -174,6 +174,26 @@ describe("CLI Config Command (subcommands)", () => { expect(mockExit).toHaveBeenCalledWith(1); }); + + it("should parse trusted_hostnames as an array and persist normalized entries", async () => { + const cmd = getCommand(); + await cmd.parseAsync(["set", "trusted_hostnames", "Example.COM,app.example.com."], { + from: "user", + }); + + expect(mockConfig.set).toHaveBeenCalledWith("trusted_hostnames", [ + "example.com", + "app.example.com", + ]); + }); + + it("should exit(1) on invalid hostname in trusted_hostnames", async () => { + const cmd = getCommand(); + await cmd.parseAsync(["set", "trusted_hostnames", "good.com,bad value"], { from: "user" }); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockConfig.set).not.toHaveBeenCalledWith("trusted_hostnames", expect.anything()); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/cli/test/utils.test.ts b/packages/cli/test/utils.test.ts index 78288f9c..f3511322 100644 --- a/packages/cli/test/utils.test.ts +++ b/packages/cli/test/utils.test.ts @@ -104,6 +104,25 @@ describe("CLI Utils", () => { expect(parseConfigValue("sk-test123")).toBe("sk-test123"); expect(parseConfigValue("")).toBe(""); }); + + it("should CSV-split values for known string[] keys", () => { + expect(parseConfigValue("a.com,b.com", "trusted_hostnames")).toEqual(["a.com", "b.com"]); + expect(parseConfigValue("a.com", "trusted_hostnames")).toEqual(["a.com"]); + expect(parseConfigValue(" a.com , b.com ", "trusted_hostnames")).toEqual(["a.com", "b.com"]); + expect(parseConfigValue("", "trusted_hostnames")).toEqual([]); + }); + + it("should CSV-split values for pw_cdp_endpoints (regression for pre-existing bug)", () => { + expect(parseConfigValue("ws://a:9222,ws://b:9222", "pw_cdp_endpoints" as any)).toEqual([ + "ws://a:9222", + "ws://b:9222", + ]); + }); + + it("should still coerce booleans/numbers when key is omitted", () => { + expect(parseConfigValue("true")).toBe(true); + expect(parseConfigValue("42")).toBe(42); + }); }); describe("getPackageInfo", () => { diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 2244fced..ebd7e5b1 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -66,6 +66,9 @@ export { NoStartingUrlError, } from "./errors.js"; +// Action firewall helpers (for CLI-side validation at config-set time) +export { normalizeHostname, InvalidHostnameError } from "./security/actionFirewall.js"; + // Navigation retry configuration export type { NavigationRetryConfig } from "./browser/navigationRetry.js"; export { calculateTimeout, DEFAULT_NAVIGATION_RETRY_CONFIG } from "./browser/navigationRetry.js"; From 0e08df5c91586f94513ef57f0711b68e3ffbaaf6 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 14:50:38 -0400 Subject: [PATCH 41/44] chore: remove internal superpowers planning docs from repo These design/plan docs are internal planning artifacts, not project files, and should not ship in the open-source repo. --- .../2026-05-28-firewall-bypass-controls.md | 2039 ----------------- ...6-05-28-firewall-bypass-controls-design.md | 337 --- 2 files changed, 2376 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md delete mode 100644 docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md diff --git a/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md b/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md deleted file mode 100644 index d5562353..00000000 --- a/docs/superpowers/plans/2026-05-28-firewall-bypass-controls.md +++ /dev/null @@ -1,2039 +0,0 @@ -# Firewall Bypass Controls Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add two caller-supplied controls on top of the existing prompt-injection action firewall — a `trusted_hostnames` list that bypasses both fill and submit gates when the page and form-action hostnames all match, and an `unsafe_mode` global firewall disable. Also surface remediation guidance to the user when a block fires in non-interactive mode. - -**Architecture:** Extend the pure firewall policy with a `FirewallConfig` input and short-circuit branches in front of the existing structural rules. Surface the new controls through `WebAgentOptions`, `PiloConfig`, CLI flags, and (dev-mode) env vars. On block in non-interactive mode, emit a structured `FIREWALL_BLOCKED_NON_INTERACTIVE` event for user-facing channels only; the model-visible tool-result error stays minimal. - -**Tech Stack:** TypeScript, Vitest, Playwright, AI SDK tools, Commander, eventemitter3, existing `AriaBrowser` / `webActionTools` / `ConfigManager` modules. - -**Builds on:** `docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md`. - ---- - -## File Structure - -| Action | Path | Responsibility | -| ------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Modify | `packages/core/src/security/actionFirewall.ts` | `FirewallConfig` type, `normalizeHostname`, `extractHostname`, `InvalidHostnameError`, bypass branches in `assessFill`/`assessFormSubmission` | -| Modify | `packages/core/src/browser/ariaBrowser.ts` | Add `submitterActionUrl` to `FormSubmissionContext` | -| Modify | `packages/core/src/browser/playwrightBrowser.ts` | Resolve and return `submitterActionUrl` | -| Modify | `packages/core/src/events.ts` | Add `FIREWALL_BLOCKED_NON_INTERACTIVE` event type + data type | -| Modify | `packages/core/src/tools/webActionTools.ts` | Extend `WebActionContext` with `firewall` and `interactive`; query page hostname; pass to firewall; emit non-interactive event on block | -| Modify | `packages/core/src/webAgent.ts` | Add `trustedHostnames` / `unsafeMode` options; build frozen `FirewallConfig`; thread `interactive` into tool context | -| Modify | `packages/core/src/config/defaults.ts` | New `trusted_hostnames` (string[]) and `unsafe_mode` (boolean) fields with warning descriptions | -| Modify | `packages/core/src/config/commander.ts` | (No code change expected — `addConfigOptions` already handles `string[]` and `boolean` types automatically) | -| Modify | `packages/core/src/config/env.ts` | (No code change expected — generic env coercion handles both new fields) | -| Modify | `packages/cli/src/commands/run.ts` | Pass `trustedHostnames` / `unsafeMode` from merged config into `WebAgent`; subscribe to `FIREWALL_BLOCKED_NON_INTERACTIVE` and print remediation footer | -| Modify | `packages/core/src/index.ts` and `packages/core/src/core.ts` | Re-export `InvalidHostnameError` if it needs to be caught by callers | -| Create | `packages/core/test/security/actionFirewall.test.ts` | Add pure tests for normalization + bypass logic (file already exists per prior plan; new test cases appended) | -| Modify | `packages/core/test/tools/webActionTools.test.ts` | Tool-level tests for bypass and remediation event | -| Modify | `packages/core/test/playwrightBrowser.test.ts` | Test that `submitterActionUrl` is resolved and returned | -| Modify | `packages/core/test/webAgent.test.ts` | Integration tests for option plumbing and end-to-end bypass behavior | -| Modify | `README.md` (root) | Add "Security model" subsection | - ---- - -## Task 1: Hostname normalization and extraction helpers - -**Files:** - -- Modify: `packages/core/src/security/actionFirewall.ts` -- Test: `packages/core/test/security/actionFirewall.test.ts` - -- [ ] **Step 1: Write failing tests for `normalizeHostname` and `extractHostname`** - -Append to `packages/core/test/security/actionFirewall.test.ts`: - -```ts -import { describe, it, expect } from "vitest"; -import { - normalizeHostname, - extractHostname, - InvalidHostnameError, -} from "../../src/security/actionFirewall.js"; - -describe("normalizeHostname", () => { - it("lowercases input", () => { - expect(normalizeHostname("Example.COM")).toBe("example.com"); - }); - - it("strips a single trailing dot", () => { - expect(normalizeHostname("example.com.")).toBe("example.com"); - }); - - it("accepts bare hostnames", () => { - expect(normalizeHostname("app.example.com")).toBe("app.example.com"); - }); - - it("accepts IDN punycode", () => { - expect(normalizeHostname("xn--mnich-kva.de")).toBe("xn--mnich-kva.de"); - }); - - it("accepts bare IPv4 literals", () => { - expect(normalizeHostname("127.0.0.1")).toBe("127.0.0.1"); - }); - - it("rejects empty string", () => { - expect(() => normalizeHostname("")).toThrow(InvalidHostnameError); - }); - - it("rejects whitespace-only", () => { - expect(() => normalizeHostname(" ")).toThrow(InvalidHostnameError); - }); - - it("rejects strings with whitespace", () => { - expect(() => normalizeHostname("ex ample.com")).toThrow(InvalidHostnameError); - }); - - it("rejects strings with slashes", () => { - expect(() => normalizeHostname("example.com/path")).toThrow(InvalidHostnameError); - }); - - it("rejects strings with colons", () => { - expect(() => normalizeHostname("example.com:8080")).toThrow(InvalidHostnameError); - }); - - it("rejects strings with wildcards", () => { - expect(() => normalizeHostname("*.example.com")).toThrow(InvalidHostnameError); - }); - - it("rejects URL inputs with scheme", () => { - expect(() => normalizeHostname("https://example.com")).toThrow(InvalidHostnameError); - }); - - it("rejects bracketed IPv6 in v1", () => { - expect(() => normalizeHostname("[::1]")).toThrow(InvalidHostnameError); - }); - - it("error message names the bad entry", () => { - try { - normalizeHostname("bad value"); - } catch (e) { - expect(e).toBeInstanceOf(InvalidHostnameError); - expect((e as Error).message).toContain("bad value"); - } - }); -}); - -describe("extractHostname", () => { - it("returns lowercase hostname for https URLs", () => { - expect(extractHostname("https://Example.COM/path?q=1")).toBe("example.com"); - }); - - it("returns lowercase hostname for http URLs", () => { - expect(extractHostname("http://app.example.com")).toBe("app.example.com"); - }); - - it("strips trailing dot", () => { - expect(extractHostname("https://example.com./")).toBe("example.com"); - }); - - it("returns null for null input", () => { - expect(extractHostname(null)).toBeNull(); - }); - - it("returns null for about:blank", () => { - expect(extractHostname("about:blank")).toBeNull(); - }); - - it("returns null for data: URLs", () => { - expect(extractHostname("data:text/html,

x

")).toBeNull(); - }); - - it("returns null for file: URLs", () => { - expect(extractHostname("file:///tmp/foo.html")).toBeNull(); - }); - - it("returns null for javascript: URLs", () => { - expect(extractHostname("javascript:alert(1)")).toBeNull(); - }); - - it("returns null for malformed URLs", () => { - expect(extractHostname("not a url")).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(extractHostname("")).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "normalizeHostname"` -Expected: FAIL — `normalizeHostname`, `extractHostname`, `InvalidHostnameError` not exported. - -- [ ] **Step 3: Implement the helpers in `actionFirewall.ts`** - -Append to `packages/core/src/security/actionFirewall.ts`: - -```ts -export class InvalidHostnameError extends Error { - constructor(input: string, reason: string) { - super(`Invalid hostname "${input}": ${reason}`); - this.name = "InvalidHostnameError"; - } -} - -const HOSTNAME_DISALLOWED_CHARS = /[\s/:*]/; - -export function normalizeHostname(input: string): string { - if (typeof input !== "string") { - throw new InvalidHostnameError(String(input), "not a string"); - } - const trimmed = input.trim(); - if (trimmed.length === 0) { - throw new InvalidHostnameError(input, "empty"); - } - if (HOSTNAME_DISALLOWED_CHARS.test(trimmed)) { - throw new InvalidHostnameError(input, "contains whitespace, '/', ':', or '*'"); - } - if (trimmed.startsWith("[") || trimmed.endsWith("]")) { - throw new InvalidHostnameError(input, "bracketed IPv6 is not supported"); - } - let withoutTrailingDot = trimmed; - if (withoutTrailingDot.endsWith(".")) { - withoutTrailingDot = withoutTrailingDot.slice(0, -1); - } - if (withoutTrailingDot.length === 0) { - throw new InvalidHostnameError(input, "empty after trimming trailing dot"); - } - return withoutTrailingDot.toLowerCase(); -} - -export function extractHostname(url: string | null): string | null { - if (url === null || url === undefined) return null; - if (typeof url !== "string" || url.length === 0) return null; - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return null; - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - let host = parsed.hostname.toLowerCase(); - if (host.endsWith(".")) host = host.slice(0, -1); - if (host.length === 0) return null; - return host; -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "normalizeHostname"` -Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "extractHostname"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/security/actionFirewall.ts packages/core/test/security/actionFirewall.test.ts -git commit -m "feat(core): add hostname normalization and extraction helpers" -``` - ---- - -## Task 2: FirewallConfig type and bypass branches - -**Files:** - -- Modify: `packages/core/src/security/actionFirewall.ts` -- Modify: `packages/core/test/security/actionFirewall.test.ts` -- Modify (consumer): `packages/core/src/tools/webActionTools.ts` (compile-fix only) - -- [ ] **Step 1: Write failing tests for bypass behavior** - -Append to `packages/core/test/security/actionFirewall.test.ts`: - -```ts -import { - assessFill, - assessFormSubmission, - type FirewallConfig, -} from "../../src/security/actionFirewall.js"; -import type { FieldMetadata, FormSubmissionContext } from "../../src/browser/ariaBrowser.js"; - -const freeformField: FieldMetadata = { - ref: "ref-1", - tagName: "textarea", - inputType: null, - role: null, - name: "comment", - label: "Comment", - placeholder: null, - autocomplete: null, - isContentEditable: false, - formId: null, - formAction: null, - formMethod: null, -}; - -const emptyFirewall: FirewallConfig = { - trustedHostnames: new Set(), - unsafeMode: false, -}; - -function withTrusted(hosts: string[]): FirewallConfig { - return { trustedHostnames: new Set(hosts), unsafeMode: false }; -} - -const unsafeFirewall: FirewallConfig = { - trustedHostnames: new Set(), - unsafeMode: true, -}; - -describe("assessFill bypass branches", () => { - it("unsafeMode allows any field regardless of source", () => { - const result = assessFill({ - field: freeformField, - source: "agent", - pageHostname: null, - firewall: unsafeFirewall, - }); - expect(result.allowed).toBe(true); - }); - - it("trusted page hostname allows freeform fill", () => { - const result = assessFill({ - field: freeformField, - source: "agent", - pageHostname: "example.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(true); - }); - - it("untrusted page hostname falls through to existing rules and blocks freeform", () => { - const result = assessFill({ - field: freeformField, - source: "agent", - pageHostname: "attacker.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(false); - }); - - it("pageHostname=null never bypasses", () => { - const result = assessFill({ - field: freeformField, - source: "agent", - pageHostname: null, - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(false); - }); -}); - -const baseForm: FormSubmissionContext = { - submitterRef: "submit-1", - formId: null, - actionUrl: "https://example.com/submit", - submitterActionUrl: null, - method: "post", - fields: [ - { - ref: "ref-1", - name: "comment", - tagName: "textarea", - inputType: null, - autocomplete: null, - }, - ], -}; - -describe("assessFormSubmission bypass branches", () => { - it("unsafeMode allows any form", () => { - const result = assessFormSubmission({ - form: baseForm, - approvedRefs: new Set(), - agentFilledRefs: new Set(["ref-1"]), - operationalRefs: new Set(), - pageHostname: "attacker.com", - firewall: unsafeFirewall, - }); - expect(result.allowed).toBe(true); - }); - - it("trusted page + trusted form action allows submission", () => { - const result = assessFormSubmission({ - form: baseForm, - approvedRefs: new Set(), - agentFilledRefs: new Set(["ref-1"]), - operationalRefs: new Set(), - pageHostname: "example.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(true); - }); - - it("trusted page + untrusted form action falls through and blocks", () => { - const result = assessFormSubmission({ - form: { ...baseForm, actionUrl: "https://attacker.com/exfil" }, - approvedRefs: new Set(), - agentFilledRefs: new Set(["ref-1"]), - operationalRefs: new Set(), - pageHostname: "example.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(false); - }); - - it("trusted page + null form action hostname falls through", () => { - const result = assessFormSubmission({ - form: { ...baseForm, actionUrl: "about:blank" }, - approvedRefs: new Set(), - agentFilledRefs: new Set(["ref-1"]), - operationalRefs: new Set(), - pageHostname: "example.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(false); - }); - - it("untrusted page + trusted form action falls through", () => { - const result = assessFormSubmission({ - form: baseForm, - approvedRefs: new Set(), - agentFilledRefs: new Set(["ref-1"]), - operationalRefs: new Set(), - pageHostname: "attacker.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(false); - }); - - it("checks submitter action URL when present", () => { - const result = assessFormSubmission({ - form: { - ...baseForm, - actionUrl: "https://example.com/normal", - submitterActionUrl: "https://attacker.com/override", - }, - approvedRefs: new Set(), - agentFilledRefs: new Set(["ref-1"]), - operationalRefs: new Set(), - pageHostname: "example.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(false); - }); - - it("falls through (no bypass) when nothing is agent-filled but submitter is untrusted", () => { - const result = assessFormSubmission({ - form: { ...baseForm, actionUrl: "https://attacker.com/exfil" }, - approvedRefs: new Set(), - agentFilledRefs: new Set(), - operationalRefs: new Set(), - pageHostname: "example.com", - firewall: withTrusted(["example.com"]), - }); - expect(result.allowed).toBe(true); // existing rule: no agent-filled => allowed - }); -}); -``` - -Note: this references `submitterActionUrl` on `FormSubmissionContext`. That field is added in Task 3. Compilation will fail until Task 3 lands. That's expected; complete tasks in order. - -- [ ] **Step 2: Run tests, verify they fail** - -Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts -t "bypass"` -Expected: FAIL — `FirewallConfig` and the new signature arguments do not exist. - -- [ ] **Step 3: Extend the firewall in `actionFirewall.ts`** - -Replace existing exported types and signatures with the bypass-aware versions. Full file content: - -```ts -import type { FieldMetadata, FormSubmissionContext } from "../browser/ariaBrowser.js"; - -export const SECURITY_BLOCKED_UNAUTHORIZED_FILL = - "Security policy blocked filling a submittable form field without user approval"; - -export const SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT = - "Security policy blocked submitting a form containing unauthorized agent-filled data"; - -export type FillSource = "agent" | "user-approved"; - -export type ActionFirewallResult = - | { allowed: true; operational?: boolean } - | { allowed: false; reason: string; isRecoverable: true }; - -export interface FirewallConfig { - trustedHostnames: ReadonlySet; - unsafeMode: boolean; -} - -export class InvalidHostnameError extends Error { - constructor(input: string, reason: string) { - super(`Invalid hostname "${input}": ${reason}`); - this.name = "InvalidHostnameError"; - } -} - -const HOSTNAME_DISALLOWED_CHARS = /[\s/:*]/; - -export function normalizeHostname(input: string): string { - if (typeof input !== "string") { - throw new InvalidHostnameError(String(input), "not a string"); - } - const trimmed = input.trim(); - if (trimmed.length === 0) { - throw new InvalidHostnameError(input, "empty"); - } - if (HOSTNAME_DISALLOWED_CHARS.test(trimmed)) { - throw new InvalidHostnameError(input, "contains whitespace, '/', ':', or '*'"); - } - if (trimmed.startsWith("[") || trimmed.endsWith("]")) { - throw new InvalidHostnameError(input, "bracketed IPv6 is not supported"); - } - let withoutTrailingDot = trimmed; - if (withoutTrailingDot.endsWith(".")) { - withoutTrailingDot = withoutTrailingDot.slice(0, -1); - } - if (withoutTrailingDot.length === 0) { - throw new InvalidHostnameError(input, "empty after trimming trailing dot"); - } - return withoutTrailingDot.toLowerCase(); -} - -export function extractHostname(url: string | null): string | null { - if (url === null || url === undefined) return null; - if (typeof url !== "string" || url.length === 0) return null; - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return null; - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - let host = parsed.hostname.toLowerCase(); - if (host.endsWith(".")) host = host.slice(0, -1); - if (host.length === 0) return null; - return host; -} - -const OPERATIONAL_INPUT_TYPES = new Set([ - "search", - "number", - "date", - "datetime-local", - "month", - "time", - "week", - "color", - "range", -]); - -const OPERATIONAL_ROLES = new Set(["searchbox", "combobox", "spinbutton", "slider"]); - -const SENSITIVE_AUTOCOMPLETE_TOKENS = new Set([ - "name", - "honorific-prefix", - "given-name", - "additional-name", - "family-name", - "honorific-suffix", - "nickname", - "email", - "username", - "new-password", - "current-password", - "one-time-code", - "organization", - "street-address", - "address-line1", - "address-line2", - "address-line3", - "address-level1", - "address-level2", - "address-level3", - "address-level4", - "country", - "country-name", - "postal-code", - "cc-name", - "cc-given-name", - "cc-additional-name", - "cc-family-name", - "cc-number", - "cc-exp", - "cc-exp-month", - "cc-exp-year", - "cc-csc", - "cc-type", - "transaction-currency", - "transaction-amount", - "language", - "bday", - "bday-day", - "bday-month", - "bday-year", - "sex", - "tel", - "tel-country-code", - "tel-national", - "tel-area-code", - "tel-local", - "tel-local-prefix", - "tel-local-suffix", - "tel-extension", - "impp", - "url", - "photo", -]); - -export function assessFill(input: { - field: FieldMetadata; - source: FillSource; - pageHostname: string | null; - firewall: FirewallConfig; -}): ActionFirewallResult { - if (input.firewall.unsafeMode) { - return { allowed: true }; - } - if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { - return { allowed: true }; - } - - if (input.source === "user-approved") { - return { allowed: true }; - } - - if (isOperationalField(input.field)) { - return { allowed: true, operational: true }; - } - - return { - allowed: false, - reason: SECURITY_BLOCKED_UNAUTHORIZED_FILL, - isRecoverable: true, - }; -} - -export function assessFormSubmission(input: { - form: FormSubmissionContext; - approvedRefs: ReadonlySet; - agentFilledRefs: ReadonlySet; - operationalRefs: ReadonlySet; - pageHostname: string | null; - firewall: FirewallConfig; -}): ActionFirewallResult { - if (input.firewall.unsafeMode) { - return { allowed: true }; - } - - if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { - const actionUrls = [input.form.actionUrl, input.form.submitterActionUrl]; - const allFormActionsTrusted = actionUrls.every((url) => { - const host = extractHostname(url); - return host !== null && input.firewall.trustedHostnames.has(host); - }); - if (allFormActionsTrusted) { - return { allowed: true }; - } - } - - for (const field of input.form.fields) { - if (!field.ref || !input.agentFilledRefs.has(field.ref)) continue; - if (input.approvedRefs.has(field.ref) || input.operationalRefs.has(field.ref)) continue; - - return { - allowed: false, - reason: SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, - isRecoverable: true, - }; - } - - return { allowed: true }; -} - -function isOperationalField(field: FieldMetadata): boolean { - const inputType = field.inputType?.toLowerCase() ?? null; - const role = field.role?.toLowerCase() ?? null; - - if (hasSensitiveAutocomplete(field.autocomplete)) return false; - if (field.tagName.toLowerCase() === "textarea" || field.isContentEditable) return false; - if (inputType && OPERATIONAL_INPUT_TYPES.has(inputType)) return true; - if (role && OPERATIONAL_ROLES.has(role)) return true; - return false; -} - -function hasSensitiveAutocomplete(autocomplete: string | null): boolean { - if (!autocomplete) return false; - const tokens = autocomplete.toLowerCase().split(/\s+/); - return tokens.some((token) => SENSITIVE_AUTOCOMPLETE_TOKENS.has(token)); -} -``` - -Note on the form-action check: when `submitterActionUrl` is `null`, `extractHostname(null)` returns `null` and the check fails — meaning the bypass does not apply. This is intentionally strict; if you want to allow the bypass when no submitter override is present, change the iteration to skip `null` entries. **Correct behavior:** if the only action URL is the form's `actionUrl`, bypass should still work. Update the iteration: - -Replace this snippet inside `assessFormSubmission` with the correct semantics: - -```ts -if (input.pageHostname !== null && input.firewall.trustedHostnames.has(input.pageHostname)) { - const formActionHost = extractHostname(input.form.actionUrl); - const submitterActionHost = extractHostname(input.form.submitterActionUrl); - - const formActionTrusted = - formActionHost !== null && input.firewall.trustedHostnames.has(formActionHost); - - // submitterActionUrl is optional. If null, treat as "no override" (trusted). - // If present, it must resolve to a trusted hostname. - const submitterTrusted = - input.form.submitterActionUrl === null - ? true - : submitterActionHost !== null && input.firewall.trustedHostnames.has(submitterActionHost); - - if (formActionTrusted && submitterTrusted) { - return { allowed: true }; - } -} -``` - -Use that second snippet, not the first. - -- [ ] **Step 4: Update the existing `assessFill` and `assessFormSubmission` call sites to pass the new fields** - -The existing `webActionTools.ts` and any existing tests call these without `pageHostname` and `firewall`. Compile-fix only here — actual plumbing happens in Tasks 5 and 6. Locate every caller via: - -Run: `grep -rn "assessFill\|assessFormSubmission" packages/core/src packages/core/test` - -For each caller in `packages/core/src/tools/webActionTools.ts`, add temporary fields so the build compiles: - -In `webActionTools.ts:232-235`, replace: - -```ts -const assessment = assessFill({ - field: metadata, - source: userApproved ? "user-approved" : "agent", -}); -``` - -with: - -```ts -const assessment = assessFill({ - field: metadata, - source: userApproved ? "user-approved" : "agent", - pageHostname: null, - firewall: { trustedHostnames: new Set(), unsafeMode: false }, -}); -``` - -In `webActionTools.ts:90-95` (inside `assessFormSubmissionForAction`), replace: - -```ts -const assessment = assessFormSubmission({ - form, - approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, - agentFilledRefs: context.agentFilledRefs, - operationalRefs: context.operationalRefs, -}); -``` - -with: - -```ts -const assessment = assessFormSubmission({ - form, - approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, - agentFilledRefs: context.agentFilledRefs, - operationalRefs: context.operationalRefs, - pageHostname: null, - firewall: { trustedHostnames: new Set(), unsafeMode: false }, -}); -``` - -These temporary literals preserve existing behavior (no bypass) until Task 5 replaces them with the real plumbed-through values. - -Update any existing test callers in `packages/core/test/security/actionFirewall.test.ts` from the original spec (Task 3 in the prior plan added them) to pass the same literals. Use `grep -n "assessFill\|assessFormSubmission" packages/core/test/security/actionFirewall.test.ts` to find them. - -- [ ] **Step 5: Run all firewall-related tests to verify pass** - -Run: `pnpm --dir packages/core exec vitest run test/security/actionFirewall.test.ts` -Expected: PASS. - -Run: `pnpm --filter pilo-core run typecheck` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add packages/core/src/security/actionFirewall.ts packages/core/src/tools/webActionTools.ts packages/core/test/security/actionFirewall.test.ts -git commit -m "feat(core): add FirewallConfig and bypass branches to action firewall" -``` - ---- - -## Task 3: Add `submitterActionUrl` to FormSubmissionContext - -**Files:** - -- Modify: `packages/core/src/browser/ariaBrowser.ts` -- Modify: `packages/core/src/browser/playwrightBrowser.ts` -- Modify: `packages/core/test/playwrightBrowser.test.ts` - -- [ ] **Step 1: Write a failing browser test for `submitterActionUrl`** - -Append to `packages/core/test/playwrightBrowser.test.ts` (locate the existing `describe` block that tests `getFormSubmissionContext` and add a sibling test inside it): - -```ts -it("returns submitterActionUrl when the submit button has a formaction attribute", async () => { - await page.setContent(` -
- - -
- `); - - const ctx = await browser.getFormSubmissionContext("btn", "click"); - expect(ctx).not.toBeNull(); - expect(ctx!.actionUrl).toBe("https://example.com/normal"); - expect(ctx!.submitterActionUrl).toBe("https://override.example.com/special"); -}); - -it("returns null submitterActionUrl when the submit button has no formaction", async () => { - await page.setContent(` -
- - -
- `); - - const ctx = await browser.getFormSubmissionContext("btn", "click"); - expect(ctx).not.toBeNull(); - expect(ctx!.submitterActionUrl).toBeNull(); -}); -``` - -Adapt setup to match the existing test file's pattern (page/browser fixtures). If the existing tests use a different page setup helper, use that helper instead of `setContent` directly. - -- [ ] **Step 2: Run test, verify it fails** - -Run: `pnpm --dir packages/core exec vitest run test/playwrightBrowser.test.ts -t "submitterActionUrl"` -Expected: FAIL — `submitterActionUrl` not on `FormSubmissionContext`. - -- [ ] **Step 3: Extend `FormSubmissionContext` interface** - -In `packages/core/src/browser/ariaBrowser.ts`, locate the `FormSubmissionContext` interface (around line 83) and add the field: - -```ts -export interface FormSubmissionContext { - submitterRef: string; - formId: string | null; - actionUrl: string | null; - submitterActionUrl: string | null; - method: string | null; - fields: FormFieldState[]; -} -``` - -- [ ] **Step 4: Compute `submitterActionUrl` in `playwrightBrowser.ts`** - -In `packages/core/src/browser/playwrightBrowser.ts`, locate `getFormSubmissionContext` (around line 901). Inside the `locator.evaluate` callback, compute the submitter's `formAction` if it's a button-like element with the attribute set: - -After the `getSubmissionForm` and `canSubmitForm` helper definitions (still inside the evaluate callback) and before the `return` statement, modify the existing return to include `submitterActionUrl`: - -```ts -const submitterActionUrl = (() => { - if (!(el instanceof HTMLButtonElement) && !(el instanceof HTMLInputElement)) return null; - // formaction attribute only meaningful on submit/image inputs and submit buttons - if (el instanceof HTMLInputElement && el.type !== "submit" && el.type !== "image") return null; - if (el instanceof HTMLButtonElement && el.type !== "submit") return null; - if (!el.hasAttribute("formaction")) return null; - // formAction property resolves to an absolute URL when attribute is set - return el.formAction || null; -})(); - -return { - submitterRef, - formId: form.id || null, - actionUrl: form.action || null, - submitterActionUrl, - method: form.method?.toLowerCase() || null, - fields, -}; -``` - -- [ ] **Step 5: Run test, verify it passes** - -Run: `pnpm --dir packages/core exec vitest run test/playwrightBrowser.test.ts -t "submitterActionUrl"` -Expected: PASS. - -Run: `pnpm --filter pilo-core run typecheck` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add packages/core/src/browser/ariaBrowser.ts packages/core/src/browser/playwrightBrowser.ts packages/core/test/playwrightBrowser.test.ts -git commit -m "feat(core): expose submitter formaction override on FormSubmissionContext" -``` - ---- - -## Task 4: Add `FIREWALL_BLOCKED_NON_INTERACTIVE` event type - -**Files:** - -- Modify: `packages/core/src/events.ts` - -- [ ] **Step 1: Add the event type enum value** - -In `packages/core/src/events.ts`, locate `enum WebAgentEventType` (around line 9) and add the new value at the end of the enum, before the closing brace: - -```ts - // Firewall events - FIREWALL_BLOCKED_NON_INTERACTIVE = "firewall:blocked_non_interactive", -``` - -- [ ] **Step 2: Add the data type for the event** - -Still in `packages/core/src/events.ts`, after the existing event-data interfaces (search for the file pattern; add the new interface in the same style as e.g. `BrowserActionResultEventData`): - -```ts -export type FirewallRemediation = - | { kind: "add-trusted-hostnames"; hostnames: string[]; description: string } - | { kind: "enable-interactive-mode"; description: string } - | { kind: "enable-unsafe-mode"; description: string }; - -export interface FirewallBlockedNonInteractiveEventData extends WebAgentEventData { - reason: string; - kind: "freeform-fill" | "form-submission"; - pageHostname: string | null; - formActionHostnames: string[]; - remediations: FirewallRemediation[]; -} -``` - -- [ ] **Step 3: Add the event to the discriminated union** - -In `packages/core/src/events.ts`, locate the `WebAgentEvent` discriminated union (the long `|`-chain starting around line 370). Add a new arm at the end of the union: - -```ts - | { - type: WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE; - data: FirewallBlockedNonInteractiveEventData; - }; -``` - -If the union ends with a `;` after the last arm, place the new arm before that terminator. Match the file's existing punctuation exactly. - -- [ ] **Step 4: Typecheck** - -Run: `pnpm --filter pilo-core run typecheck` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/events.ts -git commit -m "feat(core): add FIREWALL_BLOCKED_NON_INTERACTIVE event type" -``` - ---- - -## Task 5: Plumb `firewall` and `interactive` into webActionTools and emit the event - -**Files:** - -- Modify: `packages/core/src/tools/webActionTools.ts` -- Modify: `packages/core/test/tools/webActionTools.test.ts` - -- [ ] **Step 1: Write failing tool-level tests** - -Append to `packages/core/test/tools/webActionTools.test.ts`. Use the same setup pattern as the existing tests in that file (look for `createWebActionTools` usage). Add tests: - -```ts -import { WebAgentEventType, WebAgentEventEmitter } from "../../src/events.js"; -import type { FirewallConfig } from "../../src/security/actionFirewall.js"; - -describe("webActionTools firewall bypass and remediation", () => { - it("trustedHostnames allows freeform fill on a trusted page", async () => { - const browser = createMockBrowser({ - getUrl: async () => "https://example.com/page", - getFieldMetadata: async () => ({ - ref: "ref-1", - tagName: "textarea", - inputType: null, - role: null, - name: "comment", - label: "Comment", - placeholder: null, - autocomplete: null, - isContentEditable: false, - formId: null, - formAction: null, - formMethod: null, - }), - performAction: vi.fn().mockResolvedValue(undefined), - }); - const eventEmitter = new WebAgentEventEmitter(); - const firewall: FirewallConfig = { - trustedHostnames: new Set(["example.com"]), - unsafeMode: false, - }; - - const tools = createWebActionTools({ - browser, - eventEmitter, - providerConfig: stubProviderConfig, - firewall, - interactive: false, - agentFilledRefs: new Set(), - operationalRefs: new Set(), - }); - - const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); - expect(result.success).toBe(true); - expect(browser.performAction).toHaveBeenCalled(); - }); - - it("unsafeMode allows fill of any field", async () => { - const browser = createMockBrowser({ - getUrl: async () => "https://attacker.com/", - getFieldMetadata: async () => ({ - ref: "ref-1", - tagName: "textarea", - inputType: null, - role: null, - name: "comment", - label: "Comment", - placeholder: null, - autocomplete: null, - isContentEditable: false, - formId: null, - formAction: null, - formMethod: null, - }), - performAction: vi.fn().mockResolvedValue(undefined), - }); - const eventEmitter = new WebAgentEventEmitter(); - const firewall: FirewallConfig = { - trustedHostnames: new Set(), - unsafeMode: true, - }; - - const tools = createWebActionTools({ - browser, - eventEmitter, - providerConfig: stubProviderConfig, - firewall, - interactive: false, - agentFilledRefs: new Set(), - operationalRefs: new Set(), - }); - - const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); - expect(result.success).toBe(true); - }); - - it("emits FIREWALL_BLOCKED_NON_INTERACTIVE on fill block when interactive=false", async () => { - const browser = createMockBrowser({ - getUrl: async () => "https://untrusted.com/", - getFieldMetadata: async () => ({ - ref: "ref-1", - tagName: "textarea", - inputType: null, - role: null, - name: "comment", - label: "Comment", - placeholder: null, - autocomplete: null, - isContentEditable: false, - formId: null, - formAction: null, - formMethod: null, - }), - performAction: vi.fn(), - }); - const eventEmitter = new WebAgentEventEmitter(); - const events: unknown[] = []; - eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => - events.push(data), - ); - - const tools = createWebActionTools({ - browser, - eventEmitter, - providerConfig: stubProviderConfig, - firewall: { trustedHostnames: new Set(), unsafeMode: false }, - interactive: false, - agentFilledRefs: new Set(), - operationalRefs: new Set(), - }); - - const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); - expect(result.success).toBe(false); - expect(browser.performAction).not.toHaveBeenCalled(); - expect(events).toHaveLength(1); - const data = events[0] as { - kind: string; - pageHostname: string | null; - remediations: Array<{ kind: string }>; - }; - expect(data.kind).toBe("freeform-fill"); - expect(data.pageHostname).toBe("untrusted.com"); - expect(data.remediations.map((r) => r.kind).sort()).toEqual( - ["add-trusted-hostnames", "enable-interactive-mode", "enable-unsafe-mode"].sort(), - ); - }); - - it("does NOT emit FIREWALL_BLOCKED_NON_INTERACTIVE when interactive=true", async () => { - const browser = createMockBrowser({ - getUrl: async () => "https://untrusted.com/", - getFieldMetadata: async () => ({ - ref: "ref-1", - tagName: "textarea", - inputType: null, - role: null, - name: "comment", - label: "Comment", - placeholder: null, - autocomplete: null, - isContentEditable: false, - formId: null, - formAction: null, - formMethod: null, - }), - performAction: vi.fn(), - }); - const eventEmitter = new WebAgentEventEmitter(); - const events: unknown[] = []; - eventEmitter.on(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data) => - events.push(data), - ); - - const tools = createWebActionTools({ - browser, - eventEmitter, - providerConfig: stubProviderConfig, - firewall: { trustedHostnames: new Set(), unsafeMode: false }, - interactive: true, - agentFilledRefs: new Set(), - operationalRefs: new Set(), - }); - - const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); - expect(result.success).toBe(false); - expect(events).toHaveLength(0); - }); - - it("model-visible error string does not include unsafe_mode or trusted_hostnames", async () => { - const browser = createMockBrowser({ - getUrl: async () => "https://untrusted.com/", - getFieldMetadata: async () => ({ - ref: "ref-1", - tagName: "textarea", - inputType: null, - role: null, - name: "comment", - label: "Comment", - placeholder: null, - autocomplete: null, - isContentEditable: false, - formId: null, - formAction: null, - formMethod: null, - }), - performAction: vi.fn(), - }); - const tools = createWebActionTools({ - browser, - eventEmitter: new WebAgentEventEmitter(), - providerConfig: stubProviderConfig, - firewall: { trustedHostnames: new Set(), unsafeMode: false }, - interactive: false, - agentFilledRefs: new Set(), - operationalRefs: new Set(), - }); - - const result = await tools.fill.execute({ ref: "ref-1", value: "hi" }, stubExecOptions); - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - expect(result.error).not.toMatch(/unsafe_mode|trusted_hostnames|untrusted\.com/); - }); -}); -``` - -If `createMockBrowser`, `stubProviderConfig`, and `stubExecOptions` aren't already defined in the test file, follow the patterns used in existing tests in that same file. If `getUrl` is not already part of the mock-browser pattern, extend the mock factory to accept and return it. - -- [ ] **Step 2: Run tests, verify they fail** - -Run: `pnpm --dir packages/core exec vitest run test/tools/webActionTools.test.ts -t "firewall bypass"` -Expected: FAIL — `WebActionContext` does not accept `firewall` or `interactive`, event is not emitted. - -- [ ] **Step 3: Extend `WebActionContext` and wire firewall + interactive into handlers** - -In `packages/core/src/tools/webActionTools.ts`: - -3a. Add imports (top of file): - -```ts -import { - assessFill, - assessFormSubmission, - extractHostname, - type FirewallConfig, -} from "../security/actionFirewall.js"; -import type { FirewallBlockedNonInteractiveEventData, FirewallRemediation } from "../events.js"; -``` - -Replace the existing import line that brought in `assessFill, assessFormSubmission` with this combined import. - -3b. Extend `WebActionContext` (around line 24): - -```ts -interface WebActionContext { - browser: AriaBrowser; - eventEmitter: WebAgentEventEmitter; - providerConfig: ProviderConfig; - abortSignal?: AbortSignal; - approvedRefs?: ReadonlySet; - agentFilledRefs: Set; - operationalRefs: Set; - firewall: FirewallConfig; - interactive: boolean; -} -``` - -3c. Add a remediation builder helper (top of file, after `EMPTY_APPROVED_REFS`): - -```ts -function buildRemediations(blockedHostnames: string[]): FirewallRemediation[] { - const uniqueHosts = Array.from(new Set(blockedHostnames.filter((h): h is string => Boolean(h)))); - return [ - { - kind: "add-trusted-hostnames", - hostnames: uniqueHosts, - description: - uniqueHosts.length > 0 - ? `Add ${uniqueHosts.join(", ")} to trusted_hostnames to allow this action on this site.` - : "Add the page hostname to trusted_hostnames to allow this action on this site.", - }, - { - kind: "enable-interactive-mode", - description: - "Run in interactive mode by providing a UserDataCallback so the agent can ask the user to approve sensitive fields per-action via request_user_data.", - }, - { - kind: "enable-unsafe-mode", - description: - "Set unsafe_mode=true to disable the action firewall entirely. WARNING: prompt injection from page content can then drive the agent to submit any field, including personal and credential data, to attacker-controlled forms.", - }, - ]; -} - -function emitNonInteractiveBlock( - context: WebActionContext, - kind: "freeform-fill" | "form-submission", - reason: string, - pageHostname: string | null, - formActionHostnames: string[], -): void { - if (context.interactive) return; - const data: FirewallBlockedNonInteractiveEventData = { - timestamp: Date.now(), - iterationId: "", // populated by the eventEmitter middleware that adds iterationId; if no middleware, leave empty - reason, - kind, - pageHostname, - formActionHostnames, - remediations: buildRemediations( - pageHostname === null ? formActionHostnames : [pageHostname, ...formActionHostnames], - ), - }; - context.eventEmitter.emit(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, data); -} -``` - -If the existing event emit pattern in the file sets `iterationId` via a wrapper (search for `iterationId:` in this file), match that pattern. - -3d. Update the `fill.execute` handler (around line 228) to compute page hostname and call the assessment with bypass inputs, and emit on block: - -Replace the existing `fill.execute` body with: - -```ts -execute: async ({ ref, value }) => { - try { - const [metadata, pageUrl] = await Promise.all([ - context.browser.getFieldMetadata(ref), - context.browser.getUrl(), - ]); - const pageHostname = extractHostname(pageUrl); - const userApproved = Boolean(context.approvedRefs?.has(ref)); - const assessment = assessFill({ - field: metadata, - source: userApproved ? "user-approved" : "agent", - pageHostname, - firewall: context.firewall, - }); - - if (!assessment.allowed) { - emitNonInteractiveBlock(context, "freeform-fill", assessment.reason, pageHostname, []); - return failedActionResult(PageAction.Fill, assessment.reason, context, ref); - } - - const result = await performActionWithValidation(PageAction.Fill, context, ref, value); - if (result.success && !userApproved) { - context.agentFilledRefs.add(ref); - if (assessment.operational) { - context.operationalRefs.add(ref); - } - } - return result; - } catch (error) { - if (error instanceof BrowserException) { - return failedActionResult(PageAction.Fill, error.message, context, ref); - } - throw error; - } -}, -``` - -3e. Update `assessFormSubmissionForAction` (around line 78) similarly: - -```ts -async function assessFormSubmissionForAction( - action: PageAction.Click | PageAction.Enter, - context: WebActionContext, - ref: string, -): Promise { - try { - const [form, pageUrl] = await Promise.all([ - context.browser.getFormSubmissionContext( - ref, - action === PageAction.Click ? "click" : "enter", - ), - context.browser.getUrl(), - ]); - if (!form) return null; - const pageHostname = extractHostname(pageUrl); - const formActionHostnames = [ - extractHostname(form.actionUrl), - extractHostname(form.submitterActionUrl), - ].filter((h): h is string => h !== null); - - const assessment = assessFormSubmission({ - form, - approvedRefs: context.approvedRefs ?? EMPTY_APPROVED_REFS, - agentFilledRefs: context.agentFilledRefs, - operationalRefs: context.operationalRefs, - pageHostname, - firewall: context.firewall, - }); - - if (!assessment.allowed) { - emitNonInteractiveBlock( - context, - "form-submission", - assessment.reason, - pageHostname, - formActionHostnames, - ); - return failedActionResult(action, assessment.reason, context, ref); - } - } catch (error) { - if (error instanceof BrowserException) { - return failedActionResult(action, error.message, context, ref); - } - throw error; - } - - return null; -} -``` - -3f. Confirm the `createWebActionTools` guard around line 203 still validates required fields. Update it to include `firewall`: - -```ts -export function createWebActionTools(context: WebActionContext) { - if (!context.agentFilledRefs || !context.operationalRefs) { - throw new Error("Web action provenance tracking sets are required"); - } - if (!context.firewall) { - throw new Error("FirewallConfig is required on WebActionContext"); - } - if (typeof context.interactive !== "boolean") { - throw new Error("interactive flag is required on WebActionContext"); - } - ... -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -Run: `pnpm --dir packages/core exec vitest run test/tools/webActionTools.test.ts -t "firewall bypass"` -Expected: PASS. - -Run: `pnpm --filter pilo-core run typecheck` -Expected: FAIL — `webAgent.ts` does not yet pass `firewall` or `interactive` to `createWebActionTools`. This is fixed in Task 6. - -- [ ] **Step 5: Commit (typecheck failure intentional until Task 6)** - -```bash -git add packages/core/src/tools/webActionTools.ts packages/core/test/tools/webActionTools.test.ts -git commit -m "feat(core): plumb FirewallConfig and interactive flag into web action tools" -``` - ---- - -## Task 6: WebAgent option additions and FirewallConfig construction - -**Files:** - -- Modify: `packages/core/src/webAgent.ts` -- Modify: `packages/core/test/webAgent.test.ts` - -- [ ] **Step 1: Write failing integration tests** - -Append to `packages/core/test/webAgent.test.ts`. Locate the existing test setup pattern (`createWebAgent` / `WebAgent.execute` style) and add: - -```ts -describe("WebAgent firewall options", () => { - it("trustedHostnames flows into firewall config", async () => { - // Setup an agent with trustedHostnames=["example.com"]. - // Mock the model to issue a fill action on a textarea on a page at https://example.com/. - // Assert: the fill is allowed and the action result is success. - }); - - it("unsafeMode flows into firewall config", async () => { - // Setup an agent with unsafeMode=true. - // Mock the model to issue a fill on a textarea on https://untrusted.com/. - // Assert: the fill is allowed. - }); - - it("invalid hostname in trustedHostnames throws at agent construction", () => { - expect(() => createWebAgent({ trustedHostnames: ["bad value"] })).toThrow(/Invalid hostname/); - }); - - it("interactive flag is set from onUserDataRequired presence", async () => { - // Setup an agent without onUserDataRequired. Trigger a firewall-blocked fill. - // Assert: FIREWALL_BLOCKED_NON_INTERACTIVE is emitted. - // Setup another agent with a stub onUserDataRequired. Trigger a firewall-blocked fill. - // Assert: FIREWALL_BLOCKED_NON_INTERACTIVE is NOT emitted. - }); - - it("existing prompt-injection regression still blocks on non-trusted page with both bypasses off", async () => { - // Existing regression scenario from the prior plan: ensure it still blocks. - }); -}); -``` - -Replace the commented assertions with actual code following the conventions used elsewhere in `webAgent.test.ts`. Look at the existing `prompt injection` regression test to see how the model and browser are mocked. - -- [ ] **Step 2: Run tests, verify they fail** - -Run: `pnpm --dir packages/core exec vitest run test/webAgent.test.ts -t "WebAgent firewall options"` -Expected: FAIL — options don't exist. - -- [ ] **Step 3: Add the new options to `WebAgentOptions` and build `FirewallConfig`** - -In `packages/core/src/webAgent.ts`: - -3a. Add imports (top of file): - -```ts -import { normalizeHostname, type FirewallConfig } from "./security/actionFirewall.js"; -``` - -3b. Extend `WebAgentOptions` (around line 66). Add two new fields with TSDoc warnings: - -```ts -/** - * Hostnames where the action firewall is bypassed for fills and submissions. - * - * @warning On listed hosts, prompt injection from page content can drive the - * agent to fill and submit any field, including personal and credential data. - * Use only for sites you fully trust to receive your data. The bypass applies - * only when the current page hostname AND every form-action hostname (the - * form's `action` plus any submitter `formaction` override) are all in this - * list. - */ -trustedHostnames?: readonly string[]; - -/** - * Disables the action firewall entirely. - * - * @warning When true, prompt injection from page content can cause the agent - * to submit your data, including credentials, personal information, and - * conversation context, to attacker-controlled forms. Only enable for - * trusted, controlled environments. - */ -unsafeMode?: boolean; -``` - -3c. Build a frozen `FirewallConfig` at task setup. Locate the section of `WebAgent` constructor or task-start path where other config-like values are normalized (search for `options.guardrails` or similar pattern). Add a helper near the top of the class or as a module function: - -```ts -function buildFirewallConfig(options: WebAgentOptions): FirewallConfig { - const rawHostnames = options.trustedHostnames ?? []; - const normalized = rawHostnames.map((entry) => normalizeHostname(entry)); - return Object.freeze({ - trustedHostnames: new Set(normalized), - unsafeMode: Boolean(options.unsafeMode), - }); -} -``` - -3d. Wire `FirewallConfig` and `interactive` into the `createWebActionTools` call. Locate the existing call (around line 407 — search for `createWebActionTools(`) and update: - -```ts -const firewall = buildFirewallConfig(options); -const interactive = Boolean(options.onUserDataRequired); - -... - -const webActionTools = createWebActionTools({ - browser, - eventEmitter, - providerConfig: options.providerConfig, - abortSignal, - approvedRefs: approvedRefs ?? undefined, - agentFilledRefs, - operationalRefs, - firewall, - interactive, -}); -``` - -If the existing structure builds `WebActionContext` differently (e.g., a constructor pattern), match that pattern. `firewall` and `interactive` must be set before `createWebActionTools` is called. - -Ensure `buildFirewallConfig` runs synchronously before the agent loop starts so a bad hostname surfaces immediately to the caller. - -- [ ] **Step 4: Run tests, verify they pass** - -Run: `pnpm --dir packages/core exec vitest run test/webAgent.test.ts -t "WebAgent firewall options"` -Expected: PASS. - -Run: `pnpm --filter pilo-core run typecheck` -Expected: PASS. - -Run: `pnpm --filter pilo-core run test` -Expected: PASS (existing tests should still pass; regression test should still block). - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/webAgent.ts packages/core/test/webAgent.test.ts -git commit -m "feat(core): add trustedHostnames and unsafeMode to WebAgentOptions" -``` - ---- - -## Task 7: Config defaults — `trusted_hostnames` and `unsafe_mode` - -**Files:** - -- Modify: `packages/core/src/config/defaults.ts` -- Modify: `packages/core/test/config/*.test.ts` (add tests next to existing config tests) - -- [ ] **Step 1: Write failing config tests** - -Look in `packages/core/test/config/` for an existing test file (e.g., `defaults.test.ts` or similar). If none exists for parsing, create `packages/core/test/config/defaults.test.ts`. Add: - -```ts -import { describe, it, expect } from "vitest"; -import { FIELDS, DEFAULTS } from "../../src/config/defaults.js"; - -describe("config defaults: firewall fields", () => { - it("declares trusted_hostnames as string[] with empty default", () => { - expect(FIELDS.trusted_hostnames).toBeDefined(); - expect(FIELDS.trusted_hostnames.type).toBe("string[]"); - expect(FIELDS.trusted_hostnames.category).toBe("action"); - expect(DEFAULTS.trusted_hostnames).toEqual([]); - }); - - it("declares unsafe_mode as boolean with false default", () => { - expect(FIELDS.unsafe_mode).toBeDefined(); - expect(FIELDS.unsafe_mode.type).toBe("boolean"); - expect(FIELDS.unsafe_mode.category).toBe("action"); - expect(DEFAULTS.unsafe_mode).toBe(false); - }); - - it("trusted_hostnames description warns about data risk", () => { - expect(FIELDS.trusted_hostnames.description).toMatch(/WARNING/); - expect(FIELDS.trusted_hostnames.description.toLowerCase()).toContain("trust"); - }); - - it("unsafe_mode description warns about data risk", () => { - expect(FIELDS.unsafe_mode.description).toMatch(/WARNING/); - expect(FIELDS.unsafe_mode.description.toLowerCase()).toContain("firewall"); - }); -}); -``` - -- [ ] **Step 2: Run test, verify it fails** - -Run: `pnpm --dir packages/core exec vitest run test/config/defaults.test.ts -t "firewall fields"` -Expected: FAIL — fields not declared. - -- [ ] **Step 3: Add the fields to `PiloConfig`, `PiloConfigWithDefaults`, `FIELDS`, and `DEFAULTS`** - -In `packages/core/src/config/defaults.ts`: - -3a. Add to the `PiloConfig` input interface (find the existing `action` block; add after `action_timeout_ms`): - -```ts -trusted_hostnames?: string[]; -unsafe_mode?: boolean; -``` - -3b. Add to the `PiloConfigWithDefaults` resolved interface (mirror the same position): - -```ts -trusted_hostnames: string[]; -unsafe_mode: boolean; -``` - -3c. Add to the `FIELDS` registry (after the `action_timeout_ms` entry, in the same `action` category block): - -```ts -trusted_hostnames: { - default: [], - type: "string[]", - cli: "--trusted-hostnames", - placeholder: "host1,host2,...", - env: ["PILO_TRUSTED_HOSTNAMES"], - description: - "Comma-separated hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, prompt injection from page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data.", - category: "action", -}, -unsafe_mode: { - default: false, - type: "boolean", - cli: "--unsafe", - env: ["PILO_UNSAFE_MODE"], - description: - "Disables the action firewall entirely. WARNING: prompt injection from page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments.", - category: "action", -}, -``` - -3d. Add to the `DEFAULTS` constant (mirror position): - -```ts -trusted_hostnames: [], -unsafe_mode: false, -``` - -- [ ] **Step 4: Run tests, verify they pass** - -Run: `pnpm --dir packages/core exec vitest run test/config/defaults.test.ts -t "firewall fields"` -Expected: PASS. - -Run: `pnpm --filter pilo-core run typecheck` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/core/src/config/defaults.ts packages/core/test/config/defaults.test.ts -git commit -m "feat(core): add trusted_hostnames and unsafe_mode config fields" -``` - ---- - -## Task 8: CLI + env wiring (verify no code changes needed) - -**Files:** - -- Verify: `packages/core/src/config/commander.ts` -- Verify: `packages/core/src/config/env.ts` -- Modify: `packages/core/test/config/` (add CLI + env tests if not present) - -The generic `addConfigOptions` in `commander.ts` and `parseEnvConfig` in `env.ts` already handle `string[]` and `boolean` field types. No source changes are expected — verify by test. - -- [ ] **Step 1: Write CLI tests** - -Add to (or create) `packages/core/test/config/commander.test.ts`: - -```ts -import { describe, it, expect } from "vitest"; -import { Command } from "commander"; -import { addConfigOptions } from "../../src/config/commander.js"; - -describe("CLI: firewall flags", () => { - it("parses --trusted-hostnames as comma-separated list", () => { - const cmd = new Command().exitOverride(); - addConfigOptions(cmd); - cmd.action(() => {}); - cmd.parse(["node", "test", "--trusted-hostnames", "a.com,b.com"]); - const opts = cmd.opts(); - expect(opts.trustedHostnames).toEqual(["a.com", "b.com"]); - }); - - it("parses --unsafe as boolean true", () => { - const cmd = new Command().exitOverride(); - addConfigOptions(cmd); - cmd.action(() => {}); - cmd.parse(["node", "test", "--unsafe"]); - const opts = cmd.opts(); - expect(opts.unsafe).toBe(true); - }); -}); -``` - -(Commander converts kebab-case flags to camelCase option keys: `--trusted-hostnames` → `trustedHostnames`, `--unsafe` → `unsafe`.) - -- [ ] **Step 2: Write env tests** - -Add to (or create) `packages/core/test/config/env.test.ts`: - -```ts -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { parseEnvConfig } from "../../src/config/env.js"; - -describe("env: firewall fields", () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - delete process.env.PILO_TRUSTED_HOSTNAMES; - delete process.env.PILO_UNSAFE_MODE; - }); - - afterEach(() => { - process.env = { ...originalEnv }; - }); - - it("parses PILO_TRUSTED_HOSTNAMES as comma-separated list", () => { - process.env.PILO_TRUSTED_HOSTNAMES = "a.com,b.com"; - const result = parseEnvConfig(); - expect(result.trusted_hostnames).toEqual(["a.com", "b.com"]); - }); - - it("parses PILO_UNSAFE_MODE=true as boolean true", () => { - process.env.PILO_UNSAFE_MODE = "true"; - const result = parseEnvConfig(); - expect(result.unsafe_mode).toBe(true); - }); - - it("ignores PILO_UNSAFE_MODE when unset", () => { - const result = parseEnvConfig(); - expect(result.unsafe_mode).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 3: Run tests, verify they pass without any source change** - -Run: `pnpm --dir packages/core exec vitest run test/config/commander.test.ts` -Run: `pnpm --dir packages/core exec vitest run test/config/env.test.ts` -Expected: PASS for both. - -If they fail with an unexpected reason (not "expected" vs "received"), investigate whether `addConfigOptions` or `parseEnvConfig` needs adjustment. They should not. - -- [ ] **Step 4: Commit** - -```bash -git add packages/core/test/config/commander.test.ts packages/core/test/config/env.test.ts -git commit -m "test(core): verify CLI flags and env vars for firewall config" -``` - ---- - -## Task 9: CLI consumer — pass config to WebAgent and print remediation footer - -**Files:** - -- Modify: `packages/cli/src/commands/run.ts` -- Test: `packages/cli/test/commands/run.test.ts` (if test pattern exists; otherwise add a minimal unit test for the footer printer) - -- [ ] **Step 1: Locate the WebAgent construction in `pilo run`** - -Run: `grep -n "new WebAgent\|WebAgent(" packages/cli/src/commands/run.ts` - -Find the call where options are passed to `WebAgent` from the merged config. Add `trustedHostnames` and `unsafeMode`: - -```ts -const agent = new WebAgent({ - ...existingOptions, - trustedHostnames: config.trusted_hostnames, - unsafeMode: config.unsafe_mode, -}); -``` - -(Use the actual variable names from the file.) - -- [ ] **Step 2: Subscribe to the firewall event and print remediation** - -Locate the existing event subscription pattern in `run.ts` (search for `eventEmitter.on(` or `eventEmitter.onEvent(`). Add a new subscriber: - -```ts -import { WebAgentEventType, type FirewallBlockedNonInteractiveEventData } from "pilo-core"; - -// near the other eventEmitter.onEvent(...) calls: -eventEmitter.onEvent( - WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, - (data: FirewallBlockedNonInteractiveEventData) => { - printFirewallRemediation(data); - }, -); -``` - -Add the helper near the bottom of the file (or in a small adjacent module if `run.ts` is large): - -```ts -function printFirewallRemediation(data: FirewallBlockedNonInteractiveEventData): void { - const lines: string[] = []; - lines.push(""); - lines.push("Pilo: an action was blocked by the prompt-injection firewall."); - lines.push(`Reason: ${data.reason}`); - if (data.pageHostname || data.formActionHostnames.length > 0) { - const hosts = [data.pageHostname, ...data.formActionHostnames] - .filter((h): h is string => Boolean(h)) - .filter((h, i, a) => a.indexOf(h) === i); - if (hosts.length > 0) { - lines.push(`Hostnames involved: ${hosts.join(", ")}`); - } - } - lines.push("To allow this action, you can:"); - for (const r of data.remediations) { - if (r.kind === "add-trusted-hostnames") { - const cmd = - r.hostnames.length > 0 - ? `pilo config set trusted_hostnames ${r.hostnames.join(",")}` - : "pilo config set trusted_hostnames "; - lines.push(` - ${r.description} Run: ${cmd}`); - } else if (r.kind === "enable-interactive-mode") { - lines.push(` - ${r.description}`); - } else if (r.kind === "enable-unsafe-mode") { - lines.push(` - ${r.description} Run: pilo config set unsafe_mode true`); - } - } - // Use the project's existing logging convention. If the file uses console.warn for similar warnings, - // use console.warn. Otherwise use the project logger. - for (const line of lines) { - console.warn(line); - } -} -``` - -If the file uses a different logging primitive (e.g., a chalk-styled error stream), use that instead. The footer must be distinguishable from the model's tool-result line. - -- [ ] **Step 3: Add a unit test for the footer printer** - -If a test pattern exists for run.ts, add a test in the matching location. Otherwise, create `packages/cli/test/commands/run.test.ts` with a minimal test: - -```ts -import { describe, it, expect, vi, afterEach } from "vitest"; -import { printFirewallRemediation } from "../../src/commands/run.js"; -import type { FirewallBlockedNonInteractiveEventData } from "pilo-core"; - -describe("printFirewallRemediation", () => { - let warnSpy: ReturnType; - - beforeEach(() => { - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - it("prints all three remediation options with the blocked hostname", () => { - const data: FirewallBlockedNonInteractiveEventData = { - timestamp: Date.now(), - iterationId: "", - reason: "Security policy blocked submitting a form containing unauthorized agent-filled data", - kind: "form-submission", - pageHostname: "untrusted.com", - formActionHostnames: ["untrusted.com"], - remediations: [ - { - kind: "add-trusted-hostnames", - hostnames: ["untrusted.com"], - description: "Add untrusted.com to trusted_hostnames to allow this action on this site.", - }, - { - kind: "enable-interactive-mode", - description: "Run in interactive mode by providing a UserDataCallback...", - }, - { - kind: "enable-unsafe-mode", - description: "Set unsafe_mode=true to disable the action firewall entirely...", - }, - ], - }; - - printFirewallRemediation(data); - const output = warnSpy.mock.calls.map((c) => c.join(" ")).join("\n"); - expect(output).toContain("trusted_hostnames untrusted.com"); - expect(output).toContain("interactive mode"); - expect(output).toContain("unsafe_mode true"); - expect(output).toContain("untrusted.com"); - }); -}); -``` - -For this test to work, `printFirewallRemediation` must be exported from `run.ts` (add `export` to the function declaration). - -- [ ] **Step 4: Run tests, verify they pass** - -Run: `pnpm --filter pilo-cli run test` -Expected: PASS. - -Run: `pnpm --filter pilo-cli run typecheck` (or `pnpm run typecheck` from the root if there is no per-package typecheck script) -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/cli/src/commands/run.ts packages/cli/test/commands/run.test.ts -git commit -m "feat(cli): wire firewall config and print non-interactive remediation footer" -``` - ---- - -## Task 10: Documentation — TSDoc and README - -**Files:** - -- Modify: `README.md` (root) -- Verify TSDoc already added in Task 6 (`packages/core/src/webAgent.ts`) - -- [ ] **Step 1: Add a "Security model" subsection to the root README** - -Locate the existing top-level sections in `README.md`. Add a new subsection — placement is the project owner's call, but a reasonable spot is after the high-level "How it works" / "Features" section. - -Append this subsection (adapt heading level to match the surrounding doc): - -````markdown -## Security model - -Pilo treats every web page as untrusted input. By default, the **action firewall** prevents the agent from filling freeform form fields (textareas, contact-info inputs, password fields, etc.) and from submitting any form containing agent-filled values that the user did not explicitly approve. This is the structural defense against prompt-injection attacks where a page tries to coax the agent into exfiltrating data via a form. - -Two caller-supplied controls relax this protection. Both are off by default. **Enabling either weakens the firewall's data-protection guarantees.** - -### `trusted_hostnames` - -A list of hostnames on which the firewall is bypassed for fills and submissions. The bypass applies only when the current page hostname **and every form-action hostname** (the form's `action` plus any submitter `formaction` override) are all in the list. - -```bash -pilo config set trusted_hostnames example.com,app.example.com -``` -```` - -WARNING: on listed hosts, prompt injection from page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. - -### `unsafe_mode` - -A global firewall disable. When enabled, neither the fill gate nor the submit gate applies, regardless of page or form-action hostname. - -```bash -pilo config set unsafe_mode true -``` - -WARNING: prompt injection from page content can then cause the agent to submit your data, including credentials, personal information, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. - -### Remediation when a block fires - -When the firewall blocks a fill or submission and the agent is not running in interactive mode (no `UserDataCallback`), the CLI prints a footer listing the three ways the user can enable the workflow: - -- Add the involved hostnames to `trusted_hostnames`. -- Run in interactive mode so the agent can request per-field approval through `request_user_data`. -- Enable `unsafe_mode` (with the data-protection warning above). - -The footer is shown only to the user; the model that drives the agent never sees these remediation suggestions, so prompt-injected page content cannot ask the user to enable the bypasses. - -```` - -- [ ] **Step 2: Verify TSDoc was added in Task 6** - -Run: `grep -A 5 "trustedHostnames?:" packages/core/src/webAgent.ts` -Run: `grep -A 5 "unsafeMode?:" packages/core/src/webAgent.ts` -Expected: each shows a TSDoc block with `@warning` referencing the data-protection caveat. - -If the TSDoc is missing or incomplete, add it now (copy from Task 6 Step 3b). - -- [ ] **Step 3: Commit** - -```bash -git add README.md packages/core/src/webAgent.ts -git commit -m "docs: document action firewall and bypass controls" -```` - ---- - -## Task 11: Final validation - -**Files:** none (validation only) - -- [ ] **Step 1: Format** - -Run: `pnpm run format` -Expected: clean exit. - -- [ ] **Step 2: Typecheck** - -Run: `pnpm run typecheck` -Expected: PASS. - -- [ ] **Step 3: Full test suite** - -Run: `pnpm -r run test` -Expected: PASS. - -- [ ] **Step 4: Format check** - -Run: `pnpm run format:check` -Expected: PASS. - -- [ ] **Step 5: Gitleaks scan** - -Run: `gitleaks protect -v` -Expected: no leaks. If `gitleaks` is not installed locally, run `brew install gitleaks` first. - -Run: `gitleaks detect -v` -Expected: no leaks. (Existing `.gitleaksignore` entries handle historical false positives.) - -- [ ] **Step 6: Manual smoke test (one CLI run per bypass surface)** - -Run: `pnpm pilo config set trusted_hostnames example.com` -Expected: persists; `pilo config get trusted_hostnames` prints `example.com`. - -Run: `pnpm pilo config set trusted_hostnames "bad value"` -Expected: error message naming the bad entry; exit non-zero. - -Run: `pnpm pilo config unset trusted_hostnames` -Expected: clean. - -Run: `pnpm pilo --help | grep -E "trusted-hostnames|unsafe"` -Expected: both flags appear with their warning-laden descriptions. - -- [ ] **Step 7: Commit any format-only changes** - -```bash -git status -# if format made changes: -git add -A -git commit -m "chore: prettier pass after firewall bypass work" -``` - ---- - -## Out of scope - -- Wildcard / subdomain matching for trusted hostnames. -- Per-field trust overrides beyond `request_user_data`. -- Runtime banner UI for bypassed actions (documentation is the compensating control). -- Reputation- or heuristic-based trust. - -## Self-Review - -- **Spec coverage:** - - Trusted-hostname bypass conditions (page hostname + all form-action hostnames must match): covered in Task 2 (firewall logic) and Task 5 (tool plumbing). - - `unsafeMode` global disable: Task 2, Task 5. - - `submitterActionUrl` resolution: Task 3. - - User-facing remediation on block in non-interactive mode: Task 5 (event emission) and Task 9 (CLI footer). - - Model isolation (no remediation in tool result): Task 5 (test asserts `result.error` does not include `unsafe_mode`/`trusted_hostnames`/blocked hostnames). - - Hostname normalization with validation at agent construction: Task 1 (helpers) and Task 6 (called from `buildFirewallConfig`). - - Config field defaults: Task 7. - - CLI/env wiring: Task 8 (verified via tests). - - CLI consumer prints remediation: Task 9. - - TSDoc + README: Tasks 6 and 10. -- **Placeholder scan:** none ("TBD", "TODO", "implement later" not present). -- **Type consistency:** `FirewallConfig`, `assessFill`, `assessFormSubmission` signatures match across tasks. `FirewallRemediation` shape matches between Task 4 (event-data type) and Task 5 (`buildRemediations`) and Task 9 (CLI printer). `FormSubmissionContext.submitterActionUrl` introduced in Task 3 and consumed in Tasks 2 and 5. -- **Compile-fix gap:** Task 5 ends in a deliberate `webAgent.ts` typecheck failure that Task 6 fixes. Implementers must execute Tasks 5 and 6 together (or accept the intermediate red typecheck between commits) rather than stopping at Task 5. diff --git a/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md b/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md deleted file mode 100644 index 20541d29..00000000 --- a/docs/superpowers/specs/2026-05-28-firewall-bypass-controls-design.md +++ /dev/null @@ -1,337 +0,0 @@ -# Firewall Bypass Controls — Design - -**Status:** Draft for review -**Date:** 2026-05-28 -**Branch:** `stafford/tab-976-harden-pilo-against-web-content-prompt-injection` -**Builds on:** `docs/superpowers/plans/2026-05-26-prompt-injection-action-firewall.md` - -## Problem - -The prompt-injection action firewall is correct but uniform: it blocks every agent-driven freeform fill and every form submission containing agent-filled freeform values unless the field was approved through `request_user_data`. Callers have no way to relax this on sites they trust, and no way to disable it for controlled environments where the protections are not needed. - -We need two caller-supplied controls: - -1. **Trusted hostnames** — a list of hostnames on which the firewall is bypassed for both fill and submission. -2. **Unsafe mode** — a global switch that disables the firewall entirely. - -Both controls must be opt-in, default off, and surfaced in documentation as data-protection opt-outs. - -## Non-goals - -- Heuristic trust (rating sites by domain reputation, autocomplete hints, etc.). -- Per-field trust granularity beyond what `request_user_data` already provides. -- Wildcard/subdomain matching, scheme matching, or port matching in the trusted-hostname list. -- Runtime warnings, banners, or per-action telemetry for bypassed actions. - -## Security model - -The bypass logic sits in front of the existing structural firewall. Order of evaluation: - -1. If `unsafeMode` is true → `{ allowed: true }`. -2. Else if the bypass conditions for trusted hostnames are met → `{ allowed: true }`. -3. Else fall through to the existing structural rules (operational classification, approved refs, etc.). - -### Trusted-hostname bypass conditions - -- **Fill:** the current page hostname must be in the trusted set. -- **Submission:** the current page hostname AND every resolved form-action hostname (the form's `action` plus any submitter `formaction` override) must be in the trusted set. - -A page on a non-http(s) URL (`about:blank`, `data:`, `file:`) has a `null` hostname and can never satisfy the bypass. - -### Documented limitations - -When either bypass is active, prompt injection in page content can drive the agent to fill and submit any field, including credentials, personal information, and conversation context, into forms hosted by the trusted site. The bypass is a deliberate opt-out of the firewall's data-protection guarantees. This is documented on every surface where the controls appear. - -## Architecture - -The action firewall is a pure policy module. The bypass adds one input — a `FirewallConfig` carrying caller state — and one set of short-circuit branches at the top of `assessFill` and `assessFormSubmission`. No new modules are required. - -``` -WebAgentOptions ──▶ WebAgent (normalize once at task start) - │ - ▼ - FirewallConfig (frozen) - │ - ▼ - WebActionContext.firewall - │ - ▼ - webActionTools fill/click/enter handlers - │ - ▼ - assessFill / assessFormSubmission - │ - ▼ - short-circuit if bypass applies, - otherwise existing structural rules -``` - -`browser.getUrl()` is queried once per action invocation to obtain the current page hostname. Form-action hostnames come from the existing `FormSubmissionContext` extended to carry the submitter's `formaction` override. - -## Components - -### `packages/core/src/security/actionFirewall.ts` — extended - -New exported types: - -```ts -export interface FirewallConfig { - trustedHostnames: ReadonlySet; - unsafeMode: boolean; -} -``` - -New exported helpers: - -```ts -export function normalizeHostname(input: string): string; -export function extractHostname(url: string | null): string | null; -export class InvalidHostnameError extends Error {} -``` - -`normalizeHostname`: - -- Lowercases input. -- Strips a single trailing `.`. -- Rejects empty/whitespace strings, strings containing `/`, `:`, `*`, or whitespace. -- Rejects anything that parses with `new URL(input)` as having a scheme. -- Accepts bare hostnames including IDN punycode (`xn--mnich-kva.de`) and bare IPv4 literals (`127.0.0.1`). -- Rejects bracketed IPv6 (`[::1]`) and bare IPv6 in v1. -- Throws `InvalidHostnameError` with a message naming the bad entry on rejection. - -`extractHostname`: - -- Returns the lowercased hostname (trailing dot stripped) for absolute http/https URLs. -- Returns `null` for `null` input, malformed URLs, or non-http(s) schemes (`about:`, `data:`, `file:`, `javascript:`, etc.). - -`assessFill` and `assessFormSubmission` gain two new fields on their input: - -```ts -pageHostname: string | null; -firewall: FirewallConfig; -``` - -Bypass logic in both functions (in order): - -1. If `firewall.unsafeMode` → `{ allowed: true }`. -2. Else compute trust: `pageHostname !== null && firewall.trustedHostnames.has(pageHostname)`. - For `assessFormSubmission`, additionally require every form-action hostname (form action + submitter override) to be non-null and in `firewall.trustedHostnames`. -3. If trusted → `{ allowed: true }`. -4. Else fall through to the existing structural classification. - -The existing structural classification logic is unchanged. - -### `packages/core/src/browser/ariaBrowser.ts` — extended - -`FormSubmissionContext` gains: - -```ts -submitterActionUrl: string | null; -``` - -resolved to an absolute URL by the Playwright introspection layer. `actionUrl` remains the form's `action`, also resolved to absolute. - -### `packages/core/src/browser/playwrightBrowser.ts` — updated - -`getFormSubmissionContext` resolves both `actionUrl` and `submitterActionUrl` against the page's base URL. A missing `action` attribute defaults to the current page URL (matches browser semantics). - -### `packages/core/src/tools/webActionTools.ts` — updated - -`WebActionContext` gains: - -```ts -firewall: FirewallConfig; -interactive: boolean; -``` - -`interactive` is set by `WebAgent` to `Boolean(options.onUserDataRequired)` and drives the remediation event described in the "User-facing remediation on block" section. - -Tool handlers for `fill`, `click`, and `enter`: - -1. Call `browser.getUrl()` once per invocation. -2. Pass the resulting page hostname (via `extractHostname`) and `firewall` into the firewall assessment alongside existing inputs. -3. When the assessment returns `allowed: false` and `interactive === false`, emit `FIREWALL_BLOCKED_NON_INTERACTIVE` with structured remediation context. -4. No other behavior changes to the model-visible result. - -### `packages/core/src/webAgent.ts` — updated - -`WebAgentOptions` gains: - -```ts -trustedHostnames?: readonly string[]; -unsafeMode?: boolean; -``` - -At task start, WebAgent constructs a frozen `FirewallConfig`: - -- Each entry in `trustedHostnames` is passed through `normalizeHostname`. Validation errors propagate to the caller before the agent runs. -- `unsafeMode` defaults to `false`. - -`FirewallConfig` is built once per task, threaded into `createWebActionTools`. It is not recomputed per iteration. - -### `packages/core/src/config/defaults.ts` — extended - -Two new fields in the `action` category: - -| Key | Type | Default | Description (short form) | -| ------------------- | ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `trusted_hostnames` | `string[]` | `[]` | Hostnames where the action firewall is bypassed for fills and submissions. WARNING: on listed hosts, page content can drive the agent to fill and submit any field, including personal and credential data. Use only for sites you fully trust to receive your data. | -| `unsafe_mode` | `boolean` | `false` | Disables the action firewall entirely. WARNING: web page content can then cause the agent to submit your data, including credentials, personal info, and conversation context, to attacker-controlled forms. Only enable for trusted, controlled environments. | - -Hostname validation runs at `WebAgent` construction (synchronously, before any agent iteration). A bad entry surfaces at `pilo run` startup, naming the invalid value. (The CLI's generic `pilo config set` path does not currently parse `string[]` values into arrays — a pre-existing limitation shared by other array-typed config fields like `pw_cdp_endpoints` — so per-set validation is not added here.) - -### `packages/core/src/config/commander.ts` — extended - -- `--trusted-hostname ` — repeatable, collected into an array. -- `--unsafe` — boolean flag. - -Both options include the warning wording from the config descriptions. - -### `packages/core/src/config/env.ts` — extended (dev mode only) - -- `PILO_TRUSTED_HOSTNAMES` — comma-separated list. -- `PILO_UNSAFE_MODE` — `true`/`false`. - -Production mode ignores env vars (existing invariant). - -### `packages/cli/src/commands/run.ts` — updated - -Reads the merged config, builds the `WebAgentOptions` with `trustedHostnames` and `unsafeMode` from config, and passes them into `WebAgent`. No special CLI UI for bypass state. - -## User-facing remediation on block (non-interactive mode only) - -When the firewall blocks an action and the agent is **not** in interactive mode (no `onUserDataRequired` callback), the user needs to know how to enable the blocked workflow. Pilo emits a structured remediation message to user-facing channels listing every available path forward, parameterized by the blocked action's context. - -### Why interactive mode is the trigger - -In interactive mode the agent already has a path forward: `request_user_data` escalates the missing approval to the user per field. The block is recoverable in-loop. No extra user-facing messaging is needed because the standard interactive flow handles it. - -In non-interactive mode the block is terminal for that action. The user has no in-loop recourse, so we surface the configuration paths they can take to allow the action on a future run. - -### What is shown - -A `FIREWALL_BLOCKED_NON_INTERACTIVE` event is emitted on the `WebAgentEventEmitter` with structured context: - -```ts -interface FirewallBlockedNonInteractiveEventData { - reason: string; // policy reason (no field values) - kind: "freeform-fill" | "form-submission"; - pageHostname: string | null; - formActionHostnames: string[]; // empty for fills - remediations: FirewallRemediation[]; -} - -type FirewallRemediation = - | { kind: "enable-interactive-mode"; description: string } - | { kind: "add-trusted-hostnames"; hostnames: string[]; description: string } - | { kind: "enable-unsafe-mode"; description: string }; -``` - -The CLI subscribes to this event and prints a human-readable footer after the model's tool-result line. SDK callers and pilo-server can subscribe to surface the structured remediation to their end users. - -The three remediations are always included, in this order: - -1. **`add-trusted-hostnames`** — lists the page hostname and (for submissions) every form-action hostname the user would need to add. Includes the literal command `pilo config set trusted_hostnames [...]` and the SDK-equivalent option name. -2. **`enable-interactive-mode`** — instructs the caller to provide a `UserDataCallback` (`onUserDataRequired`) so the agent can request explicit user approval per field via `request_user_data`. -3. **`enable-unsafe-mode`** — disables the firewall entirely, with the documented data-protection warning. - -The remediations omit any reference to the attempted field value, consistent with the existing "no field values in errors" invariant. - -### Model isolation - -The structured remediation context is emitted to user-facing channels only. It is **not** included in the `ActionResult.error` string that goes back to the model as a tool result. The model-visible string remains the existing policy reason (`SECURITY_BLOCKED_UNAUTHORIZED_FILL` / `SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT`). This prevents prompt-injected page content from coaxing the model to suggest that the user enable `unsafe_mode` or add the attacker's hostname to `trusted_hostnames`. - -### Implementation surfaces - -- **`packages/core/src/events.ts`** — adds `FIREWALL_BLOCKED_NON_INTERACTIVE` to `WebAgentEventType` and the corresponding event data type. -- **`packages/core/src/tools/webActionTools.ts`** — when a firewall assessment returns `allowed: false`: - - Resolves the page hostname (already computed for the assessment). - - For submissions, collects every form-action hostname. - - Reads `context.interactive: boolean` (new field on `WebActionContext`, set by `WebAgent` based on the presence of `onUserDataRequired`). - - If `interactive === false`, emits `FIREWALL_BLOCKED_NON_INTERACTIVE` with the structured remediation list. - - The tool's `ActionResult.error` continues to carry only the model-visible policy reason. -- **`packages/core/src/webAgent.ts`** — populates `WebActionContext.interactive` from `Boolean(options.onUserDataRequired)`. -- **`packages/cli/src/commands/run.ts`** — listens for `FIREWALL_BLOCKED_NON_INTERACTIVE` and prints a remediation footer formatted for the terminal. The footer is distinct from the model's tool-output line and marked clearly as a Pilo-side hint. - -### Tests - -- Non-interactive mode + firewall block → `FIREWALL_BLOCKED_NON_INTERACTIVE` event emitted with both blocked hostnames and all three remediations populated. -- Interactive mode + firewall block → no `FIREWALL_BLOCKED_NON_INTERACTIVE` event. -- Model-visible `ActionResult.error` does **not** contain `unsafe_mode`, `trusted_hostnames`, or the blocked hostnames in either mode. -- CLI integration test: a non-interactive run that triggers a block prints a remediation footer naming the host that would need to be added. - -## Documentation surfaces - -The following surfaces include the explicit warning that bypassing the firewall removes data-protection guarantees. Wording is consistent across surfaces. - -- Config descriptions for `trusted_hostnames` and `unsafe_mode` (printed by `pilo config list` / `pilo config show`). -- CLI help text for `--trusted-hostname` and `--unsafe`. -- TSDoc on the new `WebAgentOptions` fields, including `@warning` blocks so warnings surface in IDE tooltips. -- A new "Security model" subsection in the root README documenting the firewall and naming both bypasses as deliberate data-protection opt-outs. This section also describes the non-interactive-mode remediation footer so users running the CLI know what to expect when a block surfaces. - -Documentation is the compensating control for the silent-observability decision on **bypassed** actions: there is no runtime banner or per-action telemetry when a bypass is in effect, so it must be unambiguous in docs that turning these on weakens protections. Blocked actions in non-interactive mode are the inverse case — they are deliberately verbose at the user-facing layer so the user can choose a path forward. - -## Error handling - -- **Invalid hostname at agent construction** — `normalizeHostname` throws `InvalidHostnameError` with a message naming the bad entry. CLI surfaces the message and exits non-zero before the agent runs. -- **`browser.getUrl()` failure** — treated as `pageHostname = null`. Bypass cannot apply; existing structural rules run as today. -- **Form action URL resolution failure** — treated as `null` hostname for that action. Bypass cannot apply for that submission; falls through to structural rules. -- **Both `unsafeMode` and `trustedHostnames` set** — `unsafeMode` short-circuits first; `trustedHostnames` is moot. - -## Backwards compatibility - -- Both new fields default to safe values (`false`, `[]`). -- Existing config files and existing callers require no changes. -- The bypass branches are additive; the structural classification is untouched when neither bypass applies. - -## Testing - -### Pure firewall tests — `packages/core/test/security/actionFirewall.test.ts` - -- `normalizeHostname`: accepts bare hostnames; lowercases; strips trailing dot; rejects schemes, paths, wildcards, whitespace, empty. -- `extractHostname`: returns hostname for http(s); returns `null` for `about:blank`, `data:`, `file:`, malformed input, `null` input. -- `assessFill` with `unsafeMode=true` → allowed for any field regardless of source. -- `assessFill` with trusted page hostname → allowed for freeform field that would otherwise block. -- `assessFill` with untrusted page hostname → falls through to existing rules. -- `assessFill` with `pageHostname=null` → never bypasses (even if `""` were in trusted set). -- `assessFormSubmission` with `unsafeMode=true` → allowed for any form. -- `assessFormSubmission` with trusted page + all form-action hostnames trusted → allowed. -- `assessFormSubmission` with trusted page + one form-action hostname untrusted → falls through and blocks. -- `assessFormSubmission` with trusted page + `null` form-action hostname → falls through. -- `assessFormSubmission` with untrusted page + trusted form-action → falls through. -- `assessFormSubmission` checks both `actionUrl` and `submitterActionUrl`. - -### Tool-level tests — `packages/core/test/tools/webActionTools.test.ts` - -- Fill of textarea on trusted page → allowed without `request_user_data`. -- Fill of textarea on untrusted page → blocked as today. -- Click submit on trusted page with form action on same trusted host → allowed. -- Click submit on trusted page with form action on untrusted host → blocked. -- `unsafeMode=true` → fill/submit allowed on any page including freeform fields and untrusted form actions. -- Blocked results never include the attempted field value (existing invariant preserved). - -### Config tests — under `packages/core/test/config/` - -- `pilo config set trusted_hostnames a.com b.com` persists normalized list. -- Invalid entry throws at parse time, naming the bad entry. -- CLI `--trusted-hostname example.com --trusted-hostname app.example.com` builds an array. -- CLI `--unsafe` flips the boolean. -- Env (dev mode): `PILO_TRUSTED_HOSTNAMES="a.com,b.com"` parses to array; `PILO_UNSAFE_MODE=true` flips boolean. -- Production mode ignores env (existing invariant). - -### WebAgent integration tests — `packages/core/test/webAgent.test.ts` - -- Options-supplied `trustedHostnames` plumbs through to tool context and gates as expected on a fake page. -- Options-supplied `unsafeMode=true` bypasses both gates end-to-end. -- Regression: existing prompt-injection test still blocks on a non-trusted page with both bypasses off. - -## Out of scope - -- Wildcard / subdomain matching. -- Per-field trust override beyond `request_user_data`. -- Runtime warning UI when a bypass is active (documentation is the compensating control). -- Reputation-based or heuristic trust. - -## Open questions - -None at design time. Implementation may surface platform-specific edge cases in URL resolution under Playwright; those will be addressed during the build-out. From 0e92f35eb44089d4074f91c2c4cc1d67a5f40f1a Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 15:28:21 -0400 Subject: [PATCH 42/44] feat(security): restrict operational form submissions to same-host Operational fields (search/filter, comboboxes) are classified from page-controlled attributes, so an attacker page could label its collector field as a search box and submit agent-typed data to its own host. Operational agent-filled submissions are now allowed only when the form action (and any submitter formaction override) is on the current page's host; unknown page host fails closed. Approved (request_user_data) fields are unaffected. --- packages/core/src/security/actionFirewall.ts | 35 +++++- .../core/test/security/actionFirewall.test.ts | 118 +++++++++++++++++- .../core/test/tools/webActionTools.test.ts | 35 ++++++ 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts index e05387f5..76ddf125 100644 --- a/packages/core/src/security/actionFirewall.ts +++ b/packages/core/src/security/actionFirewall.ts @@ -6,6 +6,9 @@ export const SECURITY_BLOCKED_UNAUTHORIZED_FILL = export const SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT = "Security policy blocked submitting a form containing unauthorized agent-filled data"; +export const SECURITY_BLOCKED_CROSS_SITE_OPERATIONAL_SUBMIT = + "Security policy blocked submitting operational field data to a site other than the current page"; + export type FillSource = "agent" | "user-approved"; export type ActionFirewallResult = @@ -145,9 +148,14 @@ export function assessFormSubmission(input: { } } + let hasOperationalAgentFill = false; for (const field of input.form.fields) { if (!field.ref || !input.agentFilledRefs.has(field.ref)) continue; - if (input.approvedRefs.has(field.ref) || input.operationalRefs.has(field.ref)) continue; + if (input.approvedRefs.has(field.ref)) continue; + if (input.operationalRefs.has(field.ref)) { + hasOperationalAgentFill = true; + continue; + } return { allowed: false, @@ -156,6 +164,31 @@ export function assessFormSubmission(input: { }; } + // Operational fields (search/filter boxes, comboboxes, etc.) are classified + // from page-controlled attributes (inputType/role) and may carry agent-typed + // text. They are exempt from the unauthorized-submit gate so the agent can + // search and filter — but that exemption must not become an exfiltration + // channel. An attacker page can label its collector field as a search box and + // point the form action at its own host. So operational agent-filled data may + // only be submitted to the current page's own host. A null page host + // (non-http(s) page, or a getUrl failure) cannot be matched, so we fail closed. + // Approved fields are excluded from this restriction: they hold the user's own + // data, entered through request_user_data, and legitimately post cross-host + // (e.g. a payment processor on a separate domain). + if (hasOperationalAgentFill) { + const sameHost = (url: string | null): boolean => + input.pageHostname !== null && extractHostname(url) === input.pageHostname; + const submitterSameHost = + input.form.submitterActionUrl === null ? true : sameHost(input.form.submitterActionUrl); + if (!sameHost(input.form.actionUrl) || !submitterSameHost) { + return { + allowed: false, + reason: SECURITY_BLOCKED_CROSS_SITE_OPERATIONAL_SUBMIT, + isRecoverable: true, + }; + } + } + return { allowed: true }; } diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts index 8d99eab2..fe3e1f02 100644 --- a/packages/core/test/security/actionFirewall.test.ts +++ b/packages/core/test/security/actionFirewall.test.ts @@ -8,6 +8,7 @@ import { InvalidHostnameError, SECURITY_BLOCKED_UNAUTHORIZED_FILL, SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, + SECURITY_BLOCKED_CROSS_SITE_OPERATIONAL_SUBMIT, type FirewallConfig, } from "../../src/security/actionFirewall.js"; @@ -154,6 +155,8 @@ describe("actionFirewall", () => { it("allows submitting forms when agent-filled fields are approved or operational", () => { const result = assessFormSubmission({ form: form({ + // form() defaults actionUrl to https://example.com/submit, so the + // operational field submits same-site as the page below. fields: [ { ref: "E1", @@ -174,7 +177,7 @@ describe("actionFirewall", () => { approvedRefs: new Set(["E2"]), agentFilledRefs: new Set(["E1", "E2"]), operationalRefs: new Set(["E1"]), - pageHostname: null, + pageHostname: "example.com", firewall: { trustedHostnames: new Set(), unsafeMode: false }, }); @@ -459,3 +462,116 @@ describe("assessFormSubmission bypass branches", () => { expect(result.allowed).toBe(true); // existing rule: no agent-filled => allowed }); }); + +describe("assessFormSubmission same-site operational restriction", () => { + const operationalForm = (overrides: Partial = {}): FormSubmissionContext => + form({ + fields: [{ ref: "E1", name: "q", tagName: "input", inputType: "search", autocomplete: null }], + ...overrides, + }); + + it("allows operational agent-filled submission to a same-site form action", () => { + const result = assessFormSubmission({ + form: operationalForm({ actionUrl: "https://example.com/search" }), + approvedRefs: new Set(), + agentFilledRefs: new Set(["E1"]), + operationalRefs: new Set(["E1"]), + pageHostname: "example.com", + firewall: withTrusted([]), + }); + expect(result.allowed).toBe(true); + }); + + it("blocks operational agent-filled submission to a cross-site form action", () => { + const result = assessFormSubmission({ + form: operationalForm({ actionUrl: "https://attacker.example/collect" }), + approvedRefs: new Set(), + agentFilledRefs: new Set(["E1"]), + operationalRefs: new Set(["E1"]), + pageHostname: "example.com", + firewall: withTrusted([]), + }); + expect(result.allowed).toBe(false); + if (result.allowed) throw new Error("Expected cross-site operational submit to be blocked"); + expect(result.reason).toBe(SECURITY_BLOCKED_CROSS_SITE_OPERATIONAL_SUBMIT); + // Error must not echo the attempted field value or its content. + expect(result.reason).not.toContain("q"); + }); + + it("blocks operational submission when the submitter formaction overrides cross-site", () => { + const result = assessFormSubmission({ + form: operationalForm({ + actionUrl: "https://example.com/search", + submitterActionUrl: "https://attacker.example/collect", + }), + approvedRefs: new Set(), + agentFilledRefs: new Set(["E1"]), + operationalRefs: new Set(["E1"]), + pageHostname: "example.com", + firewall: withTrusted([]), + }); + expect(result.allowed).toBe(false); + }); + + it("blocks operational submission when the page hostname is unknown (fail closed)", () => { + const result = assessFormSubmission({ + form: operationalForm({ actionUrl: "https://example.com/search" }), + approvedRefs: new Set(), + agentFilledRefs: new Set(["E1"]), + operationalRefs: new Set(["E1"]), + pageHostname: null, + firewall: withTrusted([]), + }); + expect(result.allowed).toBe(false); + }); + + it("does not host-restrict user-approved (non-operational) submissions", () => { + // Approved fields are filled with the user's data via request_user_data and + // are never tracked as agent-filled, so they keep submitting cross-site + // (e.g. a payment processor on a separate domain). The user authorized them. + const result = assessFormSubmission({ + form: form({ + actionUrl: "https://payments.example.net/charge", + fields: [ + { + ref: "E2", + name: "card", + tagName: "input", + inputType: "text", + autocomplete: "cc-number", + }, + ], + }), + approvedRefs: new Set(["E2"]), + agentFilledRefs: new Set(), + operationalRefs: new Set(), + pageHostname: "shop.example.com", + firewall: withTrusted([]), + }); + expect(result.allowed).toBe(true); + }); + + it("allows operational cross-site submission when unsafeMode is on", () => { + const result = assessFormSubmission({ + form: operationalForm({ actionUrl: "https://attacker.example/collect" }), + approvedRefs: new Set(), + agentFilledRefs: new Set(["E1"]), + operationalRefs: new Set(["E1"]), + pageHostname: "example.com", + firewall: unsafeFirewall, + }); + expect(result.allowed).toBe(true); + }); + + it("allows operational cross-host submission when both page and action hosts are trusted", () => { + const result = assessFormSubmission({ + form: operationalForm({ actionUrl: "https://api.example.com/search" }), + approvedRefs: new Set(), + agentFilledRefs: new Set(["E1"]), + operationalRefs: new Set(["E1"]), + pageHostname: "example.com", + firewall: withTrusted(["example.com", "api.example.com"]), + }); + expect(result.allowed).toBe(true); + }); +}); diff --git a/packages/core/test/tools/webActionTools.test.ts b/packages/core/test/tools/webActionTools.test.ts index eb1cdd59..f66b4ec5 100644 --- a/packages/core/test/tools/webActionTools.test.ts +++ b/packages/core/test/tools/webActionTools.test.ts @@ -728,6 +728,41 @@ describe("Web Action Tools", () => { expect(result.success).toBe(true); }); + it("should block click submit when an operational field posts to a cross-site action", async () => { + // The reported bypass: an attacker page labels its collector field as a + // search box (operational) and points the form action at its own host. + const performActionSpy = vi.spyOn(mockBrowser, "performAction"); + mockBrowser.url = "https://example.com/search"; + context.agentFilledRefs = new Set(["query"]); + context.operationalRefs = new Set(["query"]); + context.approvedRefs = new Set(); + mockBrowser.formSubmissionContexts.set("submit1", { + submitterRef: "submit1", + formId: "search", + actionUrl: "https://attacker.example/collect", + submitterActionUrl: null, + method: "get", + fields: [ + { + ref: "query", + name: "q", + tagName: "input", + inputType: "search", + autocomplete: null, + }, + ], + }); + tools = createWebActionTools(context); + + const result = await tools.click.execute({ ref: "submit1" }); + + expect(performActionSpy).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.error).toBe( + "Security policy blocked submitting operational field data to a site other than the current page", + ); + }); + it("should block enter submit when form contains unauthorized agent-filled fields", async () => { const formContextSpy = vi.spyOn(mockBrowser, "getFormSubmissionContext"); const performActionSpy = vi.spyOn(mockBrowser, "performAction"); From f12a8c290df17aed0a31b6abd98bc896d56e48c6 Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 15:33:29 -0400 Subject: [PATCH 43/44] chore(core): regenerate webagent-event schema for firewall events The FIREWALL_BLOCKED_NON_INTERACTIVE event types were added to events.ts without regenerating the committed schema artifact. The new check:schemas CI step caught the drift. --- packages/core/schemas/webagent-event.json | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/packages/core/schemas/webagent-event.json b/packages/core/schemas/webagent-event.json index 296a72d6..6bd2d031 100644 --- a/packages/core/schemas/webagent-event.json +++ b/packages/core/schemas/webagent-event.json @@ -3368,6 +3368,117 @@ ], "type": "object" }, + "FirewallBlockedNonInteractiveEventData": { + "additionalProperties": false, + "properties": { + "formActionHostnames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "iterationId": { + "type": "string" + }, + "kind": { + "enum": [ + "freeform-fill", + "form-submission" + ], + "type": "string" + }, + "pageHostname": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": "string" + }, + "remediations": { + "items": { + "$ref": "#/definitions/FirewallRemediation" + }, + "type": "array" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "formActionHostnames", + "iterationId", + "kind", + "pageHostname", + "reason", + "remediations", + "timestamp" + ], + "type": "object" + }, + "FirewallRemediation": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "hostnames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "const": "add-trusted-hostnames", + "type": "string" + } + }, + "required": [ + "kind", + "hostnames", + "description" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "kind": { + "const": "enable-interactive-mode", + "type": "string" + } + }, + "required": [ + "kind", + "description" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "kind": { + "const": "enable-unsafe-mode", + "type": "string" + } + }, + "required": [ + "kind", + "description" + ], + "type": "object" + } + ] + }, "FormFieldRequest": { "additionalProperties": false, "description": "A single form field the agent needs data for.", @@ -4656,6 +4767,23 @@ "data" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/definitions/FirewallBlockedNonInteractiveEventData" + }, + "type": { + "const": "firewall:blocked_non_interactive", + "type": "string" + } + }, + "required": [ + "type", + "data" + ], + "type": "object" } ], "description": "Union type of all event data types" From 931239117b33077d95758c182d3db0810b50a20c Mon Sep 17 00:00:00 2001 From: sbrooke Date: Thu, 28 May 2026 20:38:48 -0400 Subject: [PATCH 44/44] feat(security): trust the caller-provided start URL host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caller passing --url is consent to interact with that site, so the firewall now trusts the start URL's host for fills/submissions. Only the explicitly caller-provided startingUrl is trusted — not planner-chosen or agent-navigated URLs, which are model/page-influenced. --- packages/core/src/security/actionFirewall.ts | 23 ++++++++++ packages/core/src/webAgent.ts | 17 +++++++- .../core/test/security/actionFirewall.test.ts | 42 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/core/src/security/actionFirewall.ts b/packages/core/src/security/actionFirewall.ts index 76ddf125..0dde6d03 100644 --- a/packages/core/src/security/actionFirewall.ts +++ b/packages/core/src/security/actionFirewall.ts @@ -242,6 +242,29 @@ export function normalizeHostname(input: string): string { return withoutTrailingDot.toLowerCase(); } +/** + * Return a FirewallConfig with the start URL's host added to the trusted set. + * + * Used to trust the hostname of a caller-provided start URL: navigating somewhere + * the caller explicitly named is treated as consent to interact with that host's + * forms. Only call this with a caller-supplied URL — never a planner-chosen or + * agent-navigated URL, which are model/page-influenced and must not grant trust. + * + * Non-http(s) or unparseable URLs (null host) leave the firewall unchanged, as + * does a host that is already trusted. The input is never mutated. + */ +export function withTrustedStartHost( + firewall: FirewallConfig, + startUrl: string | null, +): FirewallConfig { + const host = extractHostname(startUrl); + if (host === null || firewall.trustedHostnames.has(host)) return firewall; + return Object.freeze({ + trustedHostnames: new Set([...firewall.trustedHostnames, host]), + unsafeMode: firewall.unsafeMode, + }); +} + export function extractHostname(url: string | null): string | null { if (url === null || url === undefined) return null; if (typeof url !== "string" || url.length === 0) return null; diff --git a/packages/core/src/webAgent.ts b/packages/core/src/webAgent.ts index 93fedbc5..3b02497b 100644 --- a/packages/core/src/webAgent.ts +++ b/packages/core/src/webAgent.ts @@ -60,7 +60,11 @@ import { SpanName, recordSanitizedException, } from "./telemetry/tracing.js"; -import { normalizeHostname, type FirewallConfig } from "./security/actionFirewall.js"; +import { + normalizeHostname, + withTrustedStartHost, + type FirewallConfig, +} from "./security/actionFirewall.js"; // === Type Definitions === @@ -251,6 +255,11 @@ export class WebAgent { private readonly onUserDataRequired: UserDataCallback | undefined; private readonly taskId: string | undefined; private readonly firewall: FirewallConfig; + // Host of the caller-provided start URL (options.startingUrl), captured at + // execute() time. Trusted by the firewall — navigating somewhere the caller + // explicitly named is consent to interact with that host. NOT set from the + // planner-chosen URL, which is model-influenced and must not grant trust. + private callerStartHostUrl: string | null = null; // Actions where same-action-same-value repetition is legitimate workflow // (e.g. scrolling an infinite feed, waiting for a slow page) rather than a @@ -345,6 +354,10 @@ export class WebAgent { // 1. Validate input parameters (let validation errors throw) this.validateTaskAndOptions(task, options); + // Capture only the caller-provided start URL (not the planner's choice) + // so the firewall can trust that host for fills/submissions. + this.callerStartHostUrl = options.startingUrl ?? null; + // 2. Initialize browser and internal state await this.initializeBrowserAndState(task, options); @@ -447,7 +460,7 @@ export class WebAgent { approvedRefs: approvedRefs ?? undefined, agentFilledRefs, operationalRefs, - firewall: this.firewall, + firewall: withTrustedStartHost(this.firewall, this.callerStartHostUrl), interactive: Boolean(this.onUserDataRequired), }); diff --git a/packages/core/test/security/actionFirewall.test.ts b/packages/core/test/security/actionFirewall.test.ts index fe3e1f02..e032bcee 100644 --- a/packages/core/test/security/actionFirewall.test.ts +++ b/packages/core/test/security/actionFirewall.test.ts @@ -5,6 +5,7 @@ import { assessFormSubmission, normalizeHostname, extractHostname, + withTrustedStartHost, InvalidHostnameError, SECURITY_BLOCKED_UNAUTHORIZED_FILL, SECURITY_BLOCKED_UNAUTHORIZED_SUBMIT, @@ -248,6 +249,47 @@ describe("normalizeHostname", () => { }); }); +describe("withTrustedStartHost", () => { + const base: FirewallConfig = { trustedHostnames: new Set(["already.com"]), unsafeMode: false }; + + it("adds the start URL's host to the trusted set", () => { + const result = withTrustedStartHost(base, "https://github.com/signup"); + expect(result.trustedHostnames.has("github.com")).toBe(true); + }); + + it("preserves existing trusted hosts and unsafeMode", () => { + const result = withTrustedStartHost( + { trustedHostnames: new Set(["already.com"]), unsafeMode: true }, + "https://github.com/", + ); + expect(result.trustedHostnames.has("already.com")).toBe(true); + expect(result.unsafeMode).toBe(true); + }); + + it("lowercases the host (via extractHostname)", () => { + const result = withTrustedStartHost(base, "https://GitHub.COM/x"); + expect(result.trustedHostnames.has("github.com")).toBe(true); + }); + + it("does not mutate the input firewall", () => { + withTrustedStartHost(base, "https://github.com/"); + expect(base.trustedHostnames.has("github.com")).toBe(false); + }); + + it("returns the firewall unchanged for a null start URL", () => { + expect(withTrustedStartHost(base, null)).toBe(base); + }); + + it("returns the firewall unchanged for a non-http(s) start URL", () => { + expect(withTrustedStartHost(base, "about:blank")).toBe(base); + expect(withTrustedStartHost(base, "file:///tmp/x.html")).toBe(base); + }); + + it("returns the firewall unchanged when the host is already trusted", () => { + expect(withTrustedStartHost(base, "https://already.com/path")).toBe(base); + }); +}); + describe("extractHostname", () => { it("returns lowercase hostname for https URLs", () => { expect(extractHostname("https://Example.COM/path?q=1")).toBe("example.com");