Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6def109
fix(core): block prompt context form fills
srbiv May 26, 2026
daa9200
Revert "fix(core): block prompt context form fills"
srbiv May 26, 2026
80840ad
fix(core): gate form actions by field provenance
srbiv May 26, 2026
1c8e601
fix(core): require action provenance tracking
srbiv May 26, 2026
55c48eb
fix(core): preserve form refs after fill actions
srbiv May 26, 2026
09650d5
refactor(core): simplify action firewall helpers
srbiv May 26, 2026
b0124aa
docs(core): document action firewall state invariants
srbiv May 26, 2026
3af846e
build(deps): bump the aisdk group with 5 updates (#468)
dependabot[bot] May 26, 2026
5e7a96f
build(deps): bump the react group with 2 updates (#467)
dependabot[bot] May 26, 2026
f02fc90
build(deps-dev): bump the devdependencies group with 3 updates (#469)
dependabot[bot] May 26, 2026
b04ed1c
build(deps): bump tailwindcss from 4.2.4 to 4.3.0 (#472)
dependabot[bot] May 26, 2026
69f8f25
build(deps): bump @hono/node-server from 2.0.1 to 2.0.2 (#476)
dependabot[bot] May 26, 2026
ea961d9
build(deps): bump @ghostery/adblocker-playwright from 2.15.0 to 2.17.…
dependabot[bot] May 26, 2026
2228bcf
build(deps-dev): bump @vitest/coverage-v8 from 4.1.5 to 4.1.6 (#471)
dependabot[bot] May 26, 2026
b1a0ddf
build(deps): bump @tailwindcss/vite from 4.2.4 to 4.3.0 (#473)
dependabot[bot] May 26, 2026
3bda46b
build(deps): bump tailwind-merge from 3.5.0 to 3.6.0 (#475)
dependabot[bot] May 26, 2026
bdcaa29
build(deps): bump playwright from 1.59.1 to 1.60.0 (#474)
dependabot[bot] May 26, 2026
0df797d
fix(core): normalize metadata browser errors
srbiv May 27, 2026
0860ede
chore: ignore historical gitleaks test fingerprints
srbiv May 27, 2026
e315832
Merge branch 'main' into stafford/tab-976-harden-pilo-against-web-con…
srbiv May 27, 2026
f48d1d8
Potential fix for pull request finding
srbiv May 27, 2026
44fce5f
Potential fix for pull request finding
srbiv May 27, 2026
847de27
fix(core): tighten firewall review followups
srbiv May 27, 2026
a777d81
docs: design spec for firewall bypass controls
srbiv May 28, 2026
4d37dbb
docs: implementation plan for firewall bypass controls
srbiv May 28, 2026
eb332f8
feat(core): add hostname normalization and extraction helpers
srbiv May 28, 2026
f1f5c86
feat(core): add FirewallConfig and bypass branches to action firewall
srbiv May 28, 2026
e643119
chore(core): mark firewall scaffolding literals as temporary
srbiv May 28, 2026
bef6126
feat(core): resolve submitter formaction override in getFormSubmissio…
srbiv May 28, 2026
857e118
feat(core): add FIREWALL_BLOCKED_NON_INTERACTIVE event type
srbiv May 28, 2026
dbea800
feat(core): plumb FirewallConfig and interactive flag into web action…
srbiv May 28, 2026
15a0ef2
test(core): assert full event payload shape on firewall block
srbiv May 28, 2026
fc95968
feat(core): add trustedHostnames and unsafeMode to WebAgentOptions
srbiv May 28, 2026
588b196
test(core): include firewall event in WebAgentEventType validation list
srbiv May 28, 2026
91140c3
feat(core): add trusted_hostnames and unsafe_mode config fields
srbiv May 28, 2026
9283dff
test(core): verify CLI flags and env vars for firewall config
srbiv May 28, 2026
81d11ad
feat(cli): wire firewall config and print non-interactive remediation…
srbiv May 28, 2026
00356f8
docs: document action firewall and bypass controls
srbiv May 28, 2026
5a65839
chore: prettier pass after firewall bypass work
srbiv May 28, 2026
4839ed5
docs: clarify firewall hostname validation timing in spec
srbiv May 28, 2026
e842809
fix(cli): parse string[] config values and validate trusted_hostnames…
srbiv May 28, 2026
e4ce403
Merge branch 'main' into stafford/tab-976-harden-pilo-against-web-con…
srbiv May 28, 2026
0e08df5
chore: remove internal superpowers planning docs from repo
srbiv May 28, 2026
0e92f35
feat(security): restrict operational form submissions to same-host
srbiv May 28, 2026
f12a8c2
chore(core): regenerate webagent-event schema for firewall events
srbiv May 28, 2026
9312391
feat(security): trust the caller-provided start URL host
srbiv May 29, 2026
b9f0dd3
Merge branch 'main' into stafford/tab-976-harden-pilo-against-web-con…
srbiv May 29, 2026
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
8 changes: 8 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,42 @@ pilo run --browser bidi --bidi-url "ws://127.0.0.1:9222/session" "what's the wea
- 📝 **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).
Expand Down
10 changes: 7 additions & 3 deletions packages/cli/src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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"));
Expand Down
51 changes: 50 additions & 1 deletion packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -306,6 +312,10 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
});
}

eventEmitter.onEvent(WebAgentEventType.FIREWALL_BLOCKED_NON_INTERACTIVE, (data: unknown) => {
printFirewallRemediation(data as FirewallBlockedNonInteractiveEventData);
});

// Create WebAgent
const webAgent = new WebAgent(browser, {
debug: debugMode,
Expand All @@ -321,6 +331,8 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
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,
Expand All @@ -340,3 +352,40 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
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 <host>";
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);
}
}
12 changes: 10 additions & 2 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { BROWSERS } from "pilo-core";
import { BROWSERS, FIELDS, type PiloConfig } from "pilo-core";

/**
* CLI-specific utilities and helpers
Expand Down Expand Up @@ -101,7 +101,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;
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/test/commands/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
});

// -------------------------------------------------------------------------
Expand Down
87 changes: 86 additions & 1 deletion packages/cli/test/commands/run.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -587,3 +589,86 @@ describe("CLI Run Command", () => {
});
});
});

describe("printFirewallRemediation", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

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 <host>");
});
});
19 changes: 19 additions & 0 deletions packages/cli/test/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,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", () => {
Expand Down
Loading
Loading