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
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
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
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.
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.
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" },
})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 | ||
| Audit logging | ||
| Dashboard + monitoring |
Frequently asked questions
How does Veto protect Playwright browser automation?
Does Veto work with Playwright MCP?
How do I prevent password and credit card entry?
What about the @playwright/mcp typosquatting risk?
What's the performance impact?
Related integrations
Secure your Playwright agents before they navigate somewhere they shouldn't.