Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions packages/core/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export const TOOL_STRINGS = {
formDescription:
"Brief description of the form and its purpose (e.g., 'Shipping address form', 'Account registration')",
},
fillUserData: {
description:
"Fill a field with one of the caller's Input Data values, by key. The value is inserted directly into the field and is NEVER shown to you. Use this for any field that should receive a caller-provided data value (personal info, credentials, etc.).",
ref: "Element reference from page snapshot (e.g., E###)",
key: "The Input Data key whose value to insert (see the Available keys list)",
},
webSearch: {
description:
"Search the web for information. Returns the search results page as markdown. Use when you need to find websites or information but don't know the URL.",
Expand Down Expand Up @@ -176,6 +182,7 @@ function buildToolExamples(
hasInteractive: boolean = false,
hasFileUpload: boolean = false,
advertisedUploadFiles: string[] = [],
hasUserData: boolean = false,
): string {
const lines = [
`- click({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}"}) - ${TOOL_STRINGS.webActions.click.description}`,
Expand Down Expand Up @@ -223,6 +230,12 @@ function buildToolExamples(
);
}

if (hasUserData) {
lines.push(
`- fill_user_data({"ref": "E###", "key": "membership_code"}) - ${TOOL_STRINGS.webActions.fillUserData.description}`,
);
}

lines.push(
`- done({"result": "your final answer"}) - ${TOOL_STRINGS.webActions.done.description}`,
`- abort({"reason": "what was tried and why it failed"}) - ${TOOL_STRINGS.webActions.abort.description}`,
Expand Down Expand Up @@ -452,6 +465,7 @@ const buildActionLoopSystemPrompt = (
hasInteractive: boolean = false,
hasFileUpload: boolean = false,
advertisedUploadFiles: string[] = [],
hasUserData: boolean = false,
) =>
actionLoopSystemPromptTemplate({
hasGuardrails,
Expand All @@ -465,6 +479,7 @@ const buildActionLoopSystemPrompt = (
hasInteractive,
hasFileUpload,
advertisedUploadFiles,
hasUserData,
),
currentDate: getCurrentFormattedDate(),
});
Expand All @@ -481,11 +496,9 @@ Today's Date: {{ currentDate }}
Task: {{ task }}
Success Criteria: {{ successCriteria }}
Plan: {{ plan }}
{% if data %}
Input Data:
\`\`\`json
{{ data }}
\`\`\`
{% if dataKeys %}
Input Data available (values hidden for your security — you cannot see them). Available keys: {{ dataKeys }}
To put one of these values into a field, call fill_user_data(ref, key). Never ask for or guess the values.
{% endif %}
{% if guardrails %}

Expand All @@ -501,15 +514,15 @@ export const buildTaskAndPlanPrompt = (
task: string,
successCriteria: string,
plan: string,
data?: any,
dataKeys?: string[],
guardrails?: string | null,
) =>
taskAndPlanTemplate({
task,
successCriteria,
plan,
currentDate: getCurrentFormattedDate(),
data: data ? JSON.stringify(data, null, 2) : null,
dataKeys: dataKeys && dataKeys.length ? dataKeys.join(", ") : null,
guardrails,
});

Expand Down Expand Up @@ -594,6 +607,7 @@ export const buildStepErrorFeedbackPrompt = (
hasTabstack: boolean = false,
hasFileUpload: boolean = false,
advertisedUploadFiles: string[] = [],
hasUserData: boolean = false,
) =>
stepErrorFeedbackTemplate({
error,
Expand All @@ -604,6 +618,7 @@ export const buildStepErrorFeedbackPrompt = (
false,
hasFileUpload,
advertisedUploadFiles,
hasUserData,
),
});

Expand Down
116 changes: 115 additions & 1 deletion packages/core/src/tools/webActionTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { tool, type ToolSet } from "ai";
import { z } from "zod";
import {
AriaBrowser,
FieldMetadata,
FileUploadConfig,
PageAction,
SCROLL_DIRECTIONS,
Expand Down Expand Up @@ -48,6 +49,12 @@ interface WebActionContext {
allowFileUpload?: false | FileUploadConfig;
/** Timeout for LLM provider calls in milliseconds (used by extract). */
llmProviderTimeoutMs?: number;
/**
* Caller-supplied Input Data, keyed by name. When present with ≥1 key, the
* fill_user_data tool is exposed so the agent can fill fields by key without
* ever seeing the values — they are substituted directly at the browser sink.
*/
data?: Record<string, unknown>;
}

/**
Expand Down Expand Up @@ -275,6 +282,18 @@ async function performActionWithValidation(
);
}

/**
* Choose the browser action for filling a caller-data field based on its type:
* <select> uses Select, checkbox/radio inputs use Check, everything else Fill.
*/
function userDataFillAction(metadata: FieldMetadata): PageAction {
const tagName = metadata.tagName.toLowerCase();
if (tagName === "select") return PageAction.Select;
const inputType = metadata.inputType?.toLowerCase() ?? null;
if (inputType === "checkbox" || inputType === "radio") return PageAction.Check;
return PageAction.Fill;
}

export function createWebActionTools(context: WebActionContext): ToolSet {
if (!context.agentFilledRefs || !context.operationalRefs) {
throw new Error("Web action provenance tracking sets are required");
Expand All @@ -286,7 +305,7 @@ export function createWebActionTools(context: WebActionContext): ToolSet {
throw new Error("interactive flag is required on WebActionContext");
}

return {
const tools: ToolSet = {
click: tool({
description: TOOL_STRINGS.webActions.click.description,
inputSchema: z.object({
Expand Down Expand Up @@ -647,4 +666,99 @@ export function createWebActionTools(context: WebActionContext): ToolSet {
},
}),
};

// Only expose fill_user_data when the caller supplied Input Data. It fills a
// field with one of those values by key: the resolved value goes straight to
// the browser sink and is NEVER returned or emitted (only the "{{key}}" mask
// or the key). It is routed through the SAME firewall gate as the regular fill
// tool and records provenance in agentFilledRefs (NEVER approvedRefs), so a
// prompt-injected model cannot exfiltrate caller data to an untrusted host.
if (context.data && Object.keys(context.data).length) {
tools.fill_user_data = tool({
description: TOOL_STRINGS.webActions.fillUserData.description,
inputSchema: z.object({
ref: z.string().describe(TOOL_STRINGS.webActions.fillUserData.ref),
key: z.string().describe(TOOL_STRINGS.webActions.fillUserData.key),
}),
execute: async ({ ref, key }) => {
const value = context.data?.[key];
if (
value == null ||
(typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
) {
return failedActionResult(
"fill_user_data",
"Unknown or non-fillable data key: " +
key +
". Available keys: " +
Object.keys(context.data ?? {}).join(", "),
context,
ref,
);
}

try {
const [metadata, pageUrl] = await Promise.all([
context.browser.getFieldMetadata(ref),
context.browser.getUrl(),
]);
const pageHostname = extractHostname(pageUrl);

// SAME firewall gate as the regular fill tool: filling caller data
// into a field on an untrusted host is blocked exactly like a
// freeform agent fill, so page-driven prompt injection cannot exfil.
const assessment = assessFill({
field: metadata,
source: "agent",
pageHostname,
firewall: context.firewall,
});
if (!assessment.allowed) {
emitNonInteractiveBlock(context, "freeform-fill", assessment.reason, pageHostname, []);
return failedActionResult("fill_user_data", assessment.reason, context, ref);
}

const action = userDataFillAction(metadata);

// Emit the KEY as a mask, never the literal value.
context.eventEmitter.emit(WebAgentEventType.AGENT_ACTION, {
action: "fill_user_data",
ref,
value: "{{" + key + "}}",
});
context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_STARTED, {
action: "fill_user_data",
ref,
value: "{{" + key + "}}",
});

// The resolved value is passed straight to the browser sink. It is
// never returned or emitted — only the key/mask is surfaced.
await context.browser.performAction(ref, action, String(value));

context.eventEmitter.emit(WebAgentEventType.BROWSER_ACTION_COMPLETED, {
success: true,
action: "fill_user_data",
});

// Record provenance so a following submit check can gate on it. Use
// agentFilledRefs/operationalRefs, NEVER approvedRefs — caller data is
// not user-approved per-field and must not bypass the submit gate.
context.agentFilledRefs.add(ref);
if (assessment.allowed && "operational" in assessment && assessment.operational) {
context.operationalRefs.add(ref);
}

return { success: true, action: "fill_user_data", ref, key };
} catch (error) {
if (error instanceof BrowserException) {
return failedActionResult("fill_user_data", error.message, context, ref);
}
throw error;
}
},
});
}

return tools;
}
14 changes: 12 additions & 2 deletions packages/core/src/webAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,9 @@ export class WebAgent {
approvedRefs = result.approvedRefs;
}

// Setup tools once
// Setup tools once. Passing `data` exposes fill_user_data (only when ≥1 key
// is present); it fills caller data by key through the same firewall gate as
// fill, so the model never sees the values and cannot exfiltrate them.
const webActionTools = createWebActionTools({
browser: this.browser,
eventEmitter: this.eventEmitter,
Expand All @@ -576,6 +578,7 @@ export class WebAgent {
interactive: Boolean(this.onUserDataRequired),
allowFileUpload: this.allowFileUpload,
llmProviderTimeoutMs: this.llmProviderTimeoutMs,
data: this.data ?? undefined,
});

// Only include search tools if a search service was created
Expand Down Expand Up @@ -892,6 +895,7 @@ export class WebAgent {
Boolean(this.tabstackApiKey),
this.hasFileUpload,
this.advertisedUploadFiles,
this.hasUserData,
);
this.messages.push({ role: "user", content: errorFeedback });
}
Expand All @@ -900,6 +904,10 @@ export class WebAgent {
return this.allowFileUpload !== false && this.allowFileUpload.allowedPaths.length > 0;
}

private get hasUserData(): boolean {
return Boolean(this.data && Object.keys(this.data).length > 0);
}

/** Remove image parts from the most recent user message (fallback for AI SDK image crashes). */
private stripImagesFromLastMessage(): void {
for (let i = this.messages.length - 1; i >= 0; i--) {
Expand Down Expand Up @@ -1930,12 +1938,13 @@ export class WebAgent {
const hasTabstack = Boolean(this.tabstackApiKey);
const hasStartingUrl = Boolean(this.url && this.url !== "about:blank");
const hasInteractive = Boolean(this.onUserDataRequired);
const hasUserData = this.hasUserData;

const taskPromptContent = buildTaskAndPlanPrompt(
task,
this.successCriteria,
this.plan,
this.data,
this.data ? Object.keys(this.data) : undefined,
this.guardrails,
);

Expand All @@ -1947,6 +1956,7 @@ export class WebAgent {
hasInteractive,
this.hasFileUpload,
this.advertisedUploadFiles,
hasUserData,
);

this.messages = [
Expand Down
Loading
Loading