Integrations/Playwright

Playwright runtime authorization

Wrap Playwright MCP tools with Veto. Each governed Playwright MCP tool call is evaluated before dispatch: allow, review, or deny, with an exportable decision record per governed decision.

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 broad browser access: navigation, form filling, clicking, screenshots, and JavaScript execution.

Browser automation risks

Playwright MCP is a natural target because browser automation can read pages, click buttons, and submit forms with a user's authenticated session. Microsoft's official@playwright/mcp package. The review point is not the package name. It is whether the agent may use the browser tool for this page, this tenant, this form, and this data.

Blocked navigation

Agent navigates to admin panels, internal tools, or restricted domains. With the user's cookies, the agent inherits that authenticated session.

Data exfiltration

Agent takes screenshots of confidential pages, extracts customer data from workspaces, 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 can execute before a human notices.

First governed call

1. Install

sh
npm install veto-sdk playwright

2. Wrap Playwright actions with a GuardedPage class

guarded-page.ts
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.approved.example/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.yaml
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://(.*\\.approved\\.invalid|.*\\.google\\.invalid)/.*"
    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.ts
import { chromium } from "playwright"

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

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

await browser.close()

Unrestricted access to pages, forms, and buttons. Credentials typed directly into login fields. Delete buttons clicked without review.

With Veto
after.ts
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.approved.example")
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 check each governed tool call before it reaches the Playwright server.

mcp-playwright.ts
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.approved.example" },
})
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
Decision records
Review queue + records

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 policies. Actions can be allowed, sent to review, or denied based on URL patterns, selectors, and page context.
Does Veto work with Playwright MCP?
Yes. Veto wraps the MCP client's callTool method. Each governed 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 block governed password and credit-card entry?
Define selector-based policies that match sensitive fields. Form fills on selectors matching password, credit_card, cvv, or ssn patterns are denied on the governed path. The agent receives a structured error while the governed call stays denied.
What about the @playwright/mcp typosquatting risk?
Install the official @playwright/mcp package from Microsoft. Veto's authorization check provides defense-in-depth: even if a malicious MCP server is connected, governed calls that violate your policies are denied. URL restrictions, credential protection, and click approvals still apply.
What is the performance impact?
Runs in-process before dispatch. Browser operations (page load, rendering, network requests) usually dominate the runtime budget; policy evaluation runs before dispatch and is measurable in your own environment.

Related integrations

Secure your Playwright agents before they navigate somewhere they should not.