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
npm install veto-sdk playwright
2. Wrap Playwright actions with a GuardedPage class
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
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
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.
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.
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" },
})What Veto controls
| Capability | DIY | Veto |
|---|---|---|
| URL allowlisting | Build manually | |
| Selector-based field protection | Build 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?
Does Veto work with Playwright MCP?
How do I block governed password and credit-card entry?
What about the @playwright/mcp typosquatting risk?
What is the performance impact?
Related integrations
Secure your Playwright agents before they navigate somewhere they should not.