How We Safely Auto-Approve AI Agent Actions via Chrome DevTools Protocol
A technical deep-dive into intercepting VS Code's renderer, clicking buttons programmatically, and not destroying your codebase in the process.
Note (Feb 2026): Since this post was written, Instant Approve added a second engine using VS Code's Agent Hooks API (
PreToolUsehooks, VS Code ≥1.109). Hooks are now the primary engine; CDP remains as a fallback for older IDEs. This post describes the CDP engine only.
If you've used Cursor, Windsurf, or Copilot's agent mode, you know the drill: your AI writes code, then stops and asks "Allow this file edit?" You click Accept. It writes more code. "Run this terminal command?" You click Allow. Twenty times per session. Fifty on a productive day.
We built Instant Approve to make this stop. It auto-clicks those approval buttons so your agents run hands-free. But doing it safely — without accidentally rm -rf-ing your home directory — was the interesting engineering problem.
Here's how it works.
The Architecture: Why CDP?
VS Code, Cursor, and Windsurf are all Electron apps. Their UI is a Chromium renderer. Every dialog, button, and notification you see is a DOM element inside a browser window.
Electron exposes Chrome DevTools Protocol (CDP) when launched with --remote-debugging-port. This is the same protocol that Puppeteer and Playwright use to automate Chrome. It lets you:
- Discover open pages via
http://localhost:{port}/json - Connect to each page via WebSocket
- Execute arbitrary JavaScript in the renderer via
Runtime.evaluate
Our extension runs as a Node.js process inside VS Code's extension host. It connects to the renderer's CDP endpoint on 127.0.0.1 (localhost only — never exposed to the network) and injects a DOM observer script.
┌─────────────────────────────────────────────┐
│ YOUR MACHINE (localhost only) │
│ │
│ ┌────────────┐ WebSocket ┌───────────┐ │
│ │ Extension │── 127.0.0.1 ──▶│ Renderer │ │
│ │ (Node.js) │ :9000 │ (Chromium) │ │
│ └────────────┘ └───────────┘ │
│ │ │ │
│ │ Runtime.evaluate │ poll DOM │
│ │ (inject observer) ┌────┘ │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │ Click │ │
│ │ │ "Accept" │ │
│ │ └──────────┘ │
│ ══════╪════════════════════════════════ │
│ │ Nothing crosses this boundary │
└────────┼────────────────────────────────────┘
│
▼ (Only outbound request)
License validation → LemonSqueezy API
(key only, no code, no telemetry)
The DOM Observer: What Gets Clicked?
The injected script polls the DOM every 300ms. It uses a multi-tier search strategy:
Tier 1 — Scoped Search: Look for buttons near input boxes (.full-input-box, .composer-input-box). These are the "Run" buttons next to terminal command prompts. This is the highest-confidence match.
Tier 2 — Golden Search: Look for buttons with VS Code's primary button class (.bg-ide-button-background). These are the "Accept" / "Proceed" buttons in agent panels.
Tier 3 — Dialog Search: Look for primary buttons inside Monaco dialog boxes (.monaco-dialog-box .monaco-button.primary). These are the "Allow" permission dialogs.
Tier 4 — Global Fallback: Scan all <button> elements against a pattern matcher. This catches edge cases where the DOM structure varies across IDE versions.
Every candidate button passes through isAcceptButton() which applies two filters:
// Accept patterns — what we click
const ACCEPT_PATTERNS = [
{ pattern: 'accept', exact: false },
{ pattern: 'run', exact: false },
{ pattern: 'allow', exact: false },
{ pattern: 'confirm', exact: false },
{ pattern: 'proceed', exact: false },
{ pattern: 'retry', exact: true },
{ pattern: 'continue', exact: true },
{ pattern: 'yes', exact: true }
];
// Reject patterns — what we never click
const REJECT_PATTERNS = [
'skip', 'reject', 'cancel', 'discard', 'deny',
'close', 'delete', 'remove', 'never', 'always',
'ask', 'configure', "don't", 'detail'
];
Reject patterns take priority. A button labeled "Delete and Continue" won't be clicked because "delete" matches a reject pattern, even though "continue" matches an accept pattern.
We also exclude context menus (.monaco-menu-container), dropdown lists (.monaco-dropdown), and quick-pick selectors (.context-view). These are never auto-approved.
The Safety Net: Banned Command Detection
This is the part that matters most. When a "Run" or "Execute" button is detected, the observer doesn't just click it. First, it scans nearby DOM elements for the actual command text:
- Walk up the DOM tree (max 10 levels)
- At each level, scan previous siblings for
<pre>and<code>elements - Extract the text content of any code blocks found
- Also check the button's
aria-labelandtitleattributes
The extracted command text is then checked against a blocklist of 25+ destructive patterns:
const DEFAULT_BANNED = [
'rm -rf /', 'rm -rf ~', 'rm -rf *',
'format c:', 'del /f /s /q', 'rmdir /s /q',
':(){:|:&};:', // fork bomb
'dd if=', 'mkfs.', // disk operations
'> /dev/sda',
'chmod -R 777 /',
'shutdown', 'reboot',
'curl | sh', 'curl | bash', // pipe-to-shell
'pip install', 'npm install -g',
'sudo rm',
'drop table', 'drop database', 'truncate table',
'remove-item', 'rm -force', 'format-volume'
];
The blocklist supports regex patterns for custom rules:
{
"instantApprove.bannedCommands": [
"rm -rf",
"/docker\\s+system\\s+prune/i",
"/kubectl\\s+delete\\s+namespace/i"
]
}
If a banned command is detected, the button is not clicked, an event is emitted to the activity log, and the user is notified. The agent's request is effectively denied — it sees the same "waiting for approval" state and can reformulate.
Multi-Window: Scanning N Instances Simultaneously
One engineer running five agent conversations in five windows is our primary use case. The extension scans a port range (default: 8997–9003) every 5 seconds:
async syncSessions(injectScriptPath, config) {
this.pruneStaleConnections();
for (let port = this.basePort - this.portRange;
port <= this.basePort + this.portRange; port++) {
const pages = await this.getPages(port);
for (const page of pages) {
const id = `${port}:${page.id}`;
if (!this.connections.has(id)) {
await this.connectPage(id, page.webSocketDebuggerUrl);
}
await this.injectScript(id, injectScriptPath, config);
}
}
}
Each window gets its own WebSocket connection and its own injected observer. Stale connections (closed windows, crashed tabs) are automatically pruned. The observer script is re-injected periodically to update runtime flags like the banned command list or free tier expiration.
What We Don't Do
To be transparent about the boundaries:
- We don't read your source files. The extension reads exactly one file: its own
dom_observer.jsscript, to inject it into the renderer. - We don't modify your code. We click buttons. That's it. The agent decides what code to write.
- We don't intercept HTTP traffic. No proxy, no MITM, no network inspection.
- We don't send telemetry. No analytics, no crash reports, no usage tracking. The only network call is license key validation against LemonSqueezy's API.
- We don't run in the background. When you toggle OFF, the polling stops, all WebSocket connections are closed, and the injected observer stops itself.
The entire extension is eight JavaScript files, zero build step, one runtime dependency (ws for WebSocket). You can read every line on GitHub.
The Emergency Stop
Press Ctrl+Shift+X (or Cmd+Shift+X on macOS) to immediately halt all auto-approval across all windows. This:
- Sets
__sys_ctrl_active = falsein every injected observer - Clears all polling intervals
- Closes all CDP WebSocket connections
- Updates the status bar to show "OFF"
The observer's main loop checks __sys_ctrl_active on every tick:
function scanAndClick() {
if (!__sys_ctrl_active) return;
// ... rest of the logic
}
One boolean flip and everything stops. No graceful shutdown, no cleanup queue, no "are you sure?" dialog. Just stop.
Dry-Run Mode
For the paranoid (and you should be paranoid), enable instantApprove.dryRun in settings. The observer will scan and log what it would click, without actually clicking:
[InstantApprove] [DRY-RUN] Would click: "Accept"
[InstantApprove] [DRY-RUN] Would click: "Run npm test"
[InstantApprove] BLOCKED banned command: "rm -rf /"
We recommend running in dry-run mode for the first hour in any new environment. Check the Activity Log (Output > Instant Approve: Activity Log) to see exactly what would happen.
Failure Modes We've Considered
Q: What if the DOM structure changes in a VS Code update? A: We test against multiple tiers. If the golden search stops working, the fallback global search catches it. The accept/reject pattern matching is text-based, not class-based, so it survives CSS changes.
Q: What if an agent names a function "acceptButton" and a button appears with that text? A: The text length limit (max 50 chars) and visibility/clickability checks filter most false positives. The reject pattern list is intentionally broad. But this is why we recommend dry-run mode in unfamiliar environments.
Q: What about extensions that inject their own buttons? A: Our accept pattern is conservative. We only click buttons that match known approval/permission text. A button labeled "Deploy to Production" won't be clicked because none of our accept patterns match it.
Q: What if someone maliciously crafts a prompt to bypass the banned command list? A: The banned command list is a safety net, not a security boundary. It catches obvious mistakes and well-known destructive commands. We explicitly state in our documentation: this is a productivity tool, not a sandbox. If you need real sandboxing, use Docker or devcontainers.
Try It
Install from the VS Code Marketplace. Free tier gives you 30 minutes per day with all features. No sign-up, no credit card, no email.
The entire source is MIT-licensed and open on GitHub.
Built by PromptMeToTheMoon. Questions? security@instantapprove.dev