Integrations/Playwright

Playwright + Veto

Runtime authorization for Playwright browser automation and Playwright MCP. Control what AI agents can navigate, click, fill, and screenshot -- whether they're using Playwright directly or through MCP tool calls.

Playwright and Playwright MCP

Playwright is Microsoft's browser automation framework. Playwright MCP (@playwright/mcp) exposes Playwright as an MCP server, letting AI agents like Claude Desktop and Cursor control browsers via tool calls. Both give agents full browser access: navigation, form filling, clicking, screenshots, and JavaScript execution.

Browser automation risks

Playwright MCP is one of the most popular MCP servers, with Microsoft's official@playwright/mcp package. An unofficial typosquatted package (playwright-mcp) reached 17,000 downloads in a single week before being caught. The attack surface is real.

Unauthorized navigation

Agent navigates to admin panels, internal tools, or restricted domains. With the user's cookies, the agent has full authenticated access.

Data exfiltration

Agent takes screenshots of confidential pages, extracts customer data from dashboards, or scrapes PII from internal HR systems.

Credential exposure

Agent fills password fields on phishing pages or enters credentials into forms that log input to external servers.

Transaction triggering

Agent clicks purchase, delete, or submit buttons without human review. Financial transactions, account deletions, and data submissions happen instantly.

Quickstart

1. Install

npm install veto-sdk playwright

2. Wrap Playwright actions with a GuardedPage class

guarded-page.tstypescript
import { chromium, Page } from "playwright"
import { Veto } from "veto-sdk"

const veto = await Veto.init({ apiKey: process.env.VETO_API_KEY })

class GuardedPage {
  constructor(private page: Page) {}

  async goto(url: string) {
    const decision = await veto.guard({
      tool: "browser_navigate",
      arguments: { url },
      context: { currentUrl: this.page.url() },
    })
    if (decision.decision === 'deny') {
      throw new Error(`Navigation blocked: ${decision.reason}`)
    }
    return this.page.goto(url)
  }

  async fill(selector: string, value: string) {
    const decision = await veto.guard({
      tool: "browser_fill",
      arguments: { selector, value },
      context: { currentUrl: this.page.url() },
    })
    if (decision.decision === 'deny') {
      throw new Error(`Fill blocked: ${decision.reason}`)
    }
    return this.page.fill(selector, value)
  }

  async click(selector: string) {
    const decision = await veto.guard({
      tool: "browser_click",
      arguments: { selector },
      context: { currentUrl: this.page.url() },
    })
    if (decision.decision === 'deny') {
      throw new Error(`Click blocked: ${decision.reason}`)
    }
    if (decision.decision === 'require_approval') {
      throw new Error(
        `Approval required: ${decision.approvalId}`
      )
    }
    return this.page.click(selector)
  }

  async screenshot(opts: { path: string }) {
    const decision = await veto.guard({
      tool: "browser_screenshot",
      arguments: { path: opts.path },
      context: { currentUrl: this.page.url() },
    })
    if (decision.decision === 'deny') {
      throw new Error(`Screenshot blocked: ${decision.reason}`)
    }
    return this.page.screenshot(opts)
  }
}

const browser = await chromium.launch()
const rawPage = await browser.newPage()
const page = new GuardedPage(rawPage)

await page.goto("https://portal.company.com/reports")
await page.fill("#date-range", "2026-Q1")
await page.click("#generate-report")
await page.screenshot({ path: "/tmp/report.png" })

await browser.close()

3. Define browser policies

veto/policies.yamlyaml
version: "1.0"
name: Playwright browser automation policies

rules:
  - id: whitelist-domains
    tools: [browser_navigate]
    action: deny
    conditions:
      - field: arguments.url
        operator: not_matches
        value: "^https://(.*\\.company\\.com|.*\\.google\\.com)/.*"
    reason: "Navigation restricted to approved domains"

  - id: block-admin-pages
    tools: [browser_navigate]
    action: deny
    conditions:
      - field: arguments.url
        operator: matches
        value: ".*/admin/.*|.*/internal/.*|.*localhost.*"
    reason: "Admin and internal pages are off-limits"

  - id: block-password-fields
    tools: [browser_fill]
    action: deny
    conditions:
      - field: arguments.selector
        operator: matches
        value: ".*password.*|.*passwd.*|.*secret.*"
    reason: "Password fields cannot be filled by automation"

  - id: block-payment-fields
    tools: [browser_fill]
    action: deny
    conditions:
      - field: arguments.selector
        operator: matches
        value: ".*credit.*card.*|.*card.?number.*|.*cvv.*"
    reason: "Payment fields cannot be filled by automation"

  - id: approve-destructive-clicks
    tools: [browser_click]
    action: require_approval
    conditions:
      - field: arguments.selector
        operator: matches
        value: ".*delete.*|.*remove.*|.*submit.*|.*purchase.*|.*pay.*"
    approval:
      timeout_minutes: 10

  - id: block-sensitive-screenshots
    tools: [browser_screenshot]
    action: deny
    conditions:
      - field: context.currentUrl
        operator: matches
        value: ".*/payroll/.*|.*/hr/.*|.*/billing/.*"
    reason: "Screenshots of sensitive pages are blocked"

Before and after

Without Veto
before.tstypescript
import { chromium } from "playwright"

const browser = await chromium.launch()
const page = await browser.newPage()

await page.goto("https://internal-admin.company.com")
await page.fill("#email", "admin@company.com")
await page.fill("#password", "s3cret_p@ss")
await page.click("#login-button")
await page.click("#delete-all-users")

await browser.close()

Unrestricted access to every page, form, and button. Credentials typed directly into login fields. Delete buttons clicked without review.

With Veto
after.tstypescript
import { chromium } from "playwright"
import { Veto } from "veto-sdk"

const veto = await Veto.init({ apiKey: process.env.VETO_API_KEY })

async function guardedGoto(page, url) {
  const decision = await veto.guard({
    tool: "browser_navigate",
    arguments: { url },
    context: { currentUrl: page.url() },
  })
  if (decision.decision === 'deny') throw new Error(decision.reason)
  if (decision.decision === 'require_approval') {
    throw new Error(`Approval required: ${decision.approvalId}`)
  }
  return page.goto(url)
}

async function guardedFill(page, selector, value) {
  const decision = await veto.guard({
    tool: "browser_fill",
    arguments: { selector, value },
    context: { currentUrl: page.url() },
  })
  if (decision.decision === 'deny') throw new Error(decision.reason)
  return page.fill(selector, value)
}

async function guardedClick(page, selector) {
  const decision = await veto.guard({
    tool: "browser_click",
    arguments: { selector },
    context: { currentUrl: page.url() },
  })
  if (decision.decision === 'deny') throw new Error(decision.reason)
  if (decision.decision === 'require_approval') {
    throw new Error(`Approval required: ${decision.approvalId}`)
  }
  return page.click(selector)
}

const browser = await chromium.launch()
const page = await browser.newPage()

await guardedGoto(page, "https://portal.company.com")
await guardedFill(page, "#search", "quarterly report")
await guardedClick(page, "#search-button")

await browser.close()

Playwright MCP integration

When Playwright runs as an MCP server, AI agents call tools like browser_navigate,browser_click, andbrowser_fill through the MCP protocol. Veto wraps the MCP client to authorize every tool call before it reaches the Playwright server.

mcp-playwright.tstypescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { Veto } from "veto-sdk"

const veto = await Veto.init({ apiKey: process.env.VETO_API_KEY })

const transport = new StdioClientTransport({
  command: "npx",
  args: ["-y", "@playwright/mcp", "--headless"],
})

const client = new Client(
  { name: "guarded-playwright", version: "1.0.0" },
  { capabilities: {} },
)
await client.connect(transport)

const originalCallTool = client.callTool.bind(client)
client.callTool = async (request) => {
  const decision = await veto.guard({
    tool: request.name,
    arguments: request.arguments ?? {},
  })

  if (decision.decision === 'deny') {
    return {
      content: [
        {
          type: "text",
          text: `Blocked: ${decision.reason}`,
        },
      ],
      isError: true,
    }
  }

  if (decision.decision === 'require_approval') {
    return {
      content: [
        {
          type: "text",
          text: `Approval required: ${decision.approvalId}`,
        },
      ],
      isError: true,
    }
  }

  return originalCallTool(request)
}

const result = await client.callTool({
  name: "browser_navigate",
  arguments: { url: "https://portal.company.com" },
})
Full MCP integration guide

What Veto controls

CapabilityDIYVeto
URL allowlistingBuild manually
Selector-based field protectionBuild manually
Click approval workflows
Screenshot filtering
MCP tool call interception
Audit logging
Dashboard + monitoring

Frequently asked questions

How does Veto protect Playwright browser automation?
Veto intercepts browser actions before they execute. Each navigate, click, fill, or screenshot call is validated against your authorization policies. Actions can be allowed, blocked, or routed to human approval based on URL patterns, selectors, and page context.
Does Veto work with Playwright MCP?
Yes. Veto wraps the MCP client's callTool method. Every tool call to the Playwright MCP server passes through Veto's policy engine first. The agent receives a clear response when actions are blocked or require approval. Same policies work for both direct Playwright usage and MCP.
How do I prevent password and credit card entry?
Define selector-based policies that match sensitive fields. Any form fill on selectors matching password, credit_card, cvv, or ssn patterns is automatically denied. The agent receives a structured error and cannot bypass this protection.
What about the @playwright/mcp typosquatting risk?
Always install the official @playwright/mcp package from Microsoft. Veto's authorization layer provides defense-in-depth: even if a malicious MCP server is connected, it cannot execute tool calls that violate your policies. URL restrictions, credential protection, and click approvals still apply.
What's the performance impact?
Negligible. Veto evaluates policies in under 10ms. Browser operations (page load, rendering, network requests) take 100-2000ms each. The authorization check is invisible compared to actual browser operation time.

Related integrations

Secure your Playwright agents before they navigate somewhere they shouldn't.