Skip to content

Human approval for Claude actions with the Vulnify connector

Claude can send email, share files, and write to other tools through its connectors. Some of those actions should wait for a person: an export to an outside address, a message to a customer, a delete.

With the Vulnify connector, Claude asks Vulnify before such an action. Vulnify answers ALLOW, REVIEW, or BLOCK and records the decision. On REVIEW, Claude holds the action and tells you it is waiting for approval. A person approves or denies in Vulnify, and Claude reads the result before it goes on.

Vulnify is available in Claude’s connector directory: Connect to Claude → Select Connect on the listing, then sign in with Vulnify.

The connector is the hosted Vulnify MCP server at https://mcp.vulnify.io/mcp. After you sign in, Claude has five tools. Two matter here:

  • decide_action records a real decision for the action Claude is about to take (POST /v1/events). It writes a security event and an audit entry, and it can open a review.
  • get_decision reads that decision again (GET /v1/events/{id}). With waitMs it polls a REVIEW until it becomes ALLOW or BLOCK, for up to 30 seconds per call.

finalDecision is the result that counts. ALLOW means proceed. REVIEW means hold the action until a person decides. BLOCK means skip it.

The connector has no approve tool and no deny tool, on purpose. Claude cannot approve its own request. A person resolves the review in Vulnify.

On a personal account, open Customize > Connectors, then the Discover tab. Search for Vulnify, open the listing, and select Connect. Sign in with Vulnify, choose the organization, and allow the scopes.

On Team and Enterprise plans, an Owner or Primary Owner adds Vulnify under Organization settings > Connectors. Each member then connects with their own Vulnify account.

The scopes map to the tools:

Scope Tools
decisions:write decide_action
decisions:read get_decision
policies:read list_policies
policies:test check_action, test_policies

For this guide, allow at least decisions:write and decisions:read. Other ways to connect, including a custom connector by URL, are on Vulnify MCP server.

Claude describes each action with an agent name (for example claude), the action, the resource, the destination, and the record count. Register the agent and the resources Claude works with in the app, under the names Claude will use. An unknown agent or resource comes back as a tool error. A tool error is not an ALLOW, so Claude holds the action. Give the agent permission for the actions it may take on each resource. When the organization setting block actions an agent has no permission for is on, a missing permission blocks the action even when a policy says REVIEW. See Agents.

This rule sends any export that leaves the company to a person. The format is on Policy files.

apiVersion: vulnify.io/v1
kind: Policy
metadata:
name: review-external-exports
spec:
description: A person approves any export that leaves the company
enabled: true
action: EXPORT
resource: ANY
condition:
destination: EXTERNAL
decision: REVIEW
mode: ENFORCE
approverRoles:
- OWNER
- ADMIN

Apply it with npx -p @vulnify/sdk vulnify policies apply (an org-wide LIVE key), or create the same rule in the app under Policies. See CLI.

Ask Claude for something the rule covers, for example: Export the 800 rows of this quarter’s order summary and email them to partner@example.com. Here Customer Database is registered with the classification Internal.

  1. Before it sends anything, Claude calls decide_action:

    {
    "agent": "claude",
    "action": "EXPORT_DATA",
    "resource": "Customer Database",
    "destination": "EXTERNAL_EMAIL",
    "recordsAffected": 800
    }
  2. Vulnify returns finalDecision REVIEW, with a risk score, the reasons, the matched policy, and an event id. Measured on a test workspace: REVIEW, HIGH, score 65, reasons Export operation, External destination, Large data volume (800 records), Policy matched: review-external-exports. Claude holds the export and tells you it is waiting for approval in Vulnify.

  3. An approver resolves the review in the app, from the Slack app with Approve and Deny, or from the review email when review emails are on. A user whose role is in the policy’s approverRoles (here Owner or Admin) approves or denies. The usual review rules apply, including an authenticator code for HIGH or CRITICAL approvals when MFA is on for the approver’s account.

  4. Claude calls get_decision with the event id, and waitMs up to 30000 to wait in place. After approval, finalDecision is ALLOW, review.status is APPROVED, and Claude sends the export. After a denial or an expired review, it is BLOCK and Claude does not. If the wait ends first, waitTimedOut is true and finalDecision is still REVIEW. Ask Claude to check again later.

The stored decision stays REVIEW. Only finalDecision changes. The event and the review outcome are in the audit log.

To see the flow without a chat, call the hosted server with an API key. An API-key session lists all seven tools. Use a TEST key: its events stay in the sandbox.

Terminal window
npm install @modelcontextprotocol/sdk
import { randomUUID } from 'node:crypto';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
type Decision = { id: string; finalDecision: 'ALLOW' | 'REVIEW' | 'BLOCK'; reasons?: string[]; waitTimedOut?: boolean };
const apiKey = process.env.VULNIFY_API_KEY;
if (!apiKey) throw new Error('Set VULNIFY_API_KEY');
const client = new Client({ name: 'vulnify-guide', version: '1.0.0' });
await client.connect(
new StreamableHTTPClientTransport(new URL('https://mcp.vulnify.io/mcp'), {
requestInit: { headers: { Authorization: `Bearer ${apiKey}` } },
}),
);
async function call(name: string, args: Record<string, unknown>): Promise<Decision> {
const result = await client.callTool({ name, arguments: args });
if (result.isError) throw new Error(`${name} failed: ${JSON.stringify(result.content)}`); // an error is not an ALLOW
return result.structuredContent as Decision;
}
// 1. Before the export, record the decision.
let decision = await call('decide_action', {
agent: 'claude',
action: 'EXPORT_DATA',
resource: 'Customer Database',
destination: 'EXTERNAL_EMAIL',
recordsAffected: 800,
idempotencyKey: randomUUID(),
});
console.log('decide_action:', decision.finalDecision, decision.reasons ?? [], decision.id);
// 2. REVIEW: hold the export and wait for a person, 30 seconds per call, up to 5 minutes.
for (let i = 0; decision.finalDecision === 'REVIEW' && i < 10; i++) {
decision = await call('get_decision', { id: decision.id, waitMs: 30_000 });
console.log('get_decision:', decision.finalDecision, decision.waitTimedOut ? '(still waiting)' : '');
}
// 3. Only ALLOW runs the export.
console.log(decision.finalDecision === 'ALLOW' ? 'export runs' : `export does not run (${decision.finalDecision})`);
await client.close();

Run it with VULNIFY_API_KEY set, then approve the review in the app while it waits. idempotencyKey makes a retry return the same event instead of recording a second one.