<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Vulnify Engineering]]></title><description><![CDATA[Vulnify Engineering]]></description><link>https://vulnify.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Vulnify Engineering</title><link>https://vulnify.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 19 Oct 2026 09:27:48 GMT</lastBuildDate><atom:link href="https://vulnify.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Runtime authorization and audit for any MCP server in 2 minutes]]></title><description><![CDATA[Originally published on the Vulnify blog: https://www.vulnify.io/blog/mcp-server-firewall-runtime-authorization-audit

Plug an MCP server into an agent and the agent can call every tool that server ex]]></description><link>https://vulnify.hashnode.dev/mcp-server-firewall-runtime-authorization-audit</link><guid isPermaLink="true">https://vulnify.hashnode.dev/mcp-server-firewall-runtime-authorization-audit</guid><category><![CDATA[claude]]></category><category><![CDATA[AI]]></category><category><![CDATA[mcp]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Vulnify]]></dc:creator><pubDate>Sun, 18 Oct 2026 14:32:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aca6b6a3655f4d89686582f/f376231b-1d23-419b-9f2a-30a9ba4d6603.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Originally published on the Vulnify blog: <a href="https://www.vulnify.io/blog/mcp-server-firewall-runtime-authorization-audit">https://www.vulnify.io/blog/mcp-server-firewall-runtime-authorization-audit</a></p>
</blockquote>
<p>Plug an MCP server into an agent and the agent can call every tool that server exposes. Reading tickets or searching docs is fine. Exporting a contact list, deleting rows, sending email or creating a payment is a different story, and nothing in the protocol stops a call like that once the model decides to make it.</p>
<p>A firewall for MCP servers fills that gap. It sits on the tool call, before the side effect, and answers one question: should this specific call run?</p>
<p>That's what <a href="https://www.vulnify.io/?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall">Vulnify</a> does. Each tool call becomes an action (<code>READ_DATA</code>, <code>WRITE_DATA</code>, <code>DELETE_DATA</code>, <code>EXPORT_DATA</code>, <code>SEND_EMAIL</code>) with a destination and a record count. Vulnify answers <strong>ALLOW</strong>, <strong>REVIEW</strong> or <strong>BLOCK</strong>, with a 0-100 risk score and the reasons. REVIEW waits for a person to approve in the Vulnify app or in Slack. Every decision lands in the audit log.</p>
<p>Below are three real setups, depending on where your MCP calls happen. Pick one.</p>
<h2>Before you start</h2>
<ul>
<li>A Vulnify account. There's a free plan, no card.</li>
<li>An API key from the app (<code>vln_test_...</code> keeps events in the sandbox while you try things, <code>vln_live_...</code> for real use).</li>
<li>Node.js 18 or newer for the Claude Code hook.</li>
</ul>
<h2>Setup 1: Claude Code, every MCP tool call checked (hook)</h2>
<p>If you just connect an MCP server to Claude Code, Claude decides when to ask for permission. A <code>PreToolUse</code> hook changes that: it runs before each matching tool call, asks Vulnify first, and Claude Code obeys the answer. That's enforcement, not guidance.</p>
<p><strong>1. Install the hook</strong> from your project folder:</p>
<pre><code class="language-bash">npx @vulnify/cli hook claude-code
</code></pre>
<p>It copies the hook (one Node file, no dependencies) to <code>.claude/hooks/vulnify-hook.mjs</code> and adds a <code>PreToolUse</code> entry to <code>.claude/settings.json</code>. If <code>VULNIFY_API_KEY</code> is already exported, it uses that and saves nothing. If not, it asks for the key with hidden input and saves it to <code>.claude/settings.local.json</code> (file mode 600, added to <code>.gitignore</code>). Add <code>--user</code> to install for every project in <code>~/.claude</code>, <code>--yes</code> to skip prompts (needs <code>VULNIFY_API_KEY</code> exported), or <code>--uninstall</code> to remove it.</p>
<p><strong>2. Restart Claude Code</strong> and run <code>/hooks</code> to check it loaded.</p>
<p><strong>Manual install (fallback).</strong> Download the file and register it yourself:</p>
<pre><code class="language-bash">mkdir -p .claude/hooks
curl -fsSL https://raw.githubusercontent.com/vulnify/vulnify-examples/main/claude-code-hook/vulnify-hook.mjs \
  -o .claude/hooks/vulnify-hook.mjs
export VULNIFY_API_KEY=vln_test_...   # don't commit it
</code></pre>
<p>Then add this to <code>.claude/settings.json</code> (or <code>~/.claude/settings.json</code> for every project):</p>
<pre><code class="language-json">{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell|Write|Edit|MultiEdit|NotebookEdit|WebFetch|mcp__.*",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/vulnify-hook.mjs"],
            "timeout": 30,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}
</code></pre>
<p>The <code>mcp__.*</code> part of the matcher is the firewall: every tool from every MCP server you've added to Claude Code goes through Vulnify. <code>onFailure: "block"</code> needs Claude Code 2.1.295 or later.</p>
<p><strong>What happens to each call:</strong></p>
<table>
<thead>
<tr>
<th>Vulnify says</th>
<th>Claude Code does</th>
</tr>
</thead>
<tbody><tr>
<td>ALLOW</td>
<td>Normal permission flow</td>
</tr>
<tr>
<td>REVIEW</td>
<td>Denied with "waiting for approval in Vulnify" and the event id. Approve in the app or Slack, then ask Claude to retry</td>
</tr>
<tr>
<td>BLOCK</td>
<td>Denied, with Vulnify's reasons</td>
</tr>
<tr>
<td>Vulnify unreachable, bad key, timeout</td>
<td>Denied (fails closed), unless you opt into fail-open</td>
</tr>
</tbody></table>
<p>MCP tools are judged by name. Send and post tools become <code>SEND_EMAIL</code> (for mail servers) or <code>EXPORT_DATA</code>. Delete tools become <code>DELETE_DATA</code>. Export, share, upload, transfer and payment tools become <code>EXPORT_DATA</code>. Create and update tools become <code>WRITE_DATA</code>. Read tools are skipped unless you set <code>VULNIFY_HOOK_CHECK_READS=true</code>. The hook also covers shell commands: <code>rm</code> or <code>DROP TABLE</code> become <code>DELETE_DATA</code>, and <code>curl -X POST</code>, <code>scp</code> or <code>git push</code> become <code>EXPORT_DATA</code> to an external API.</p>
<p>Full reference: <a href="https://docs.vulnify.io/mcp/claude-code-hook/?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall">Enforce in Claude Code (hook)</a>.</p>
<h2>Setup 2: your own agent calling other MCP servers (gateway)</h2>
<p>If your agent runs in production and calls third-party MCP servers, put the decision in that path. Send the tool call to the MCP gateway first. It maps the tool name to an action, decides, records the event, and tells you whether the call is allowed:</p>
<pre><code class="language-bash">curl -X POST https://api.vulnify.io/v1/gateway/mcp \
  -H "Authorization: Bearer $VULNIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "routeId": "ROUTE_ID",
    "tool": "crm.export_contacts",
    "resource": "Customer Database",
    "destination": "EXTERNAL_EMAIL",
    "recordsAffected": 800,
    "arguments": { "limit": 800 }
  }'
</code></pre>
<p>The response is a normal decision plus a <code>gateway</code> object with <code>mappedAction</code> and <code>allowed</code>. Run the downstream tool only when <code>finalDecision</code> is <code>ALLOW</code>. Vulnify never executes <code>arguments</code>; your upstream MCP server does, after an allowing decision. The same check is available as the <code>check_mcp_tool_call</code> tool when you run the Vulnify MCP server locally with an API key (<code>npx -y @vulnify/mcp</code>).</p>
<p>Reference: <a href="https://docs.vulnify.io/api/gateway-mcp/?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall">MCP gateway</a>.</p>
<h2>Setup 3: your own MCP server (in-process adapter)</h2>
<p>If you run the MCP server yourself, guard the handlers inside it. The Node.js SDK's <code>guardMcpHandler</code> adapter wraps a tool handler and asks Vulnify before the handler body runs. Use it when the server runs in your process; use the gateway when a caller sends tool calls to Vulnify. See <a href="https://docs.vulnify.io/sdks/adapters/?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall">SDK adapters</a>.</p>
<h2>What a held call looks like</h2>
<p>Take an agent asked to export 800 records to an outside email address. Run that request in the <a href="https://www.vulnify.io/?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall#playground">playground on vulnify.io</a> (action <code>EXPORT</code>, 800 records, external email destination, data not tagged as customer PII) and the risk engine answers:</p>
<pre><code class="language-json">{
  "decision": "REVIEW",
  "riskLevel": "HIGH",
  "riskScore": 65,
  "reasons": ["Export operation", "External destination", "Large data volume (800 records)"]
}
</code></pre>
<p>The tool doesn't run. A reviewer gets the alert in Slack (Approve and Deny buttons with the Slack app) or in the Vulnify app. The decision, the reviewer and the outcome go to the audit log.</p>
<p>Tag the same data as customer PII and it gets stricter: the playground's sample policies block it outright. When no policy matches, the score comes from the rule-based risk engine. Policies let you tighten it, for example "any <code>EXPORT_DATA</code> to an external destination goes to REVIEW", or "never <code>DELETE_DATA</code> outside business hours".</p>
<h2>The audit trail</h2>
<p>Every decision is recorded: time, agent, action, resource, decision, reviewer. The audit log is kept as a SHA-256 hash chain that you can verify in the app, and you can download it as JSON or CSV with a SHA-256 checksum. When someone asks "who let the agent do that?", there's an answer.</p>
<h2>Try it</h2>
<p>Free plan, no card. Connect Claude Code or Cursor in about two minutes at <a href="https://app.vulnify.io/setup?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall">app.vulnify.io/setup</a>, or check your MCP server's tools with the <a href="https://www.vulnify.io/scanner?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall">free scanner</a> first. Popular servers already have a public scanner page, like <a href="https://www.vulnify.io/scanner/huggingface-co-mcp?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=mcp-server-firewall">Hugging Face's</a>, with a README badge maintainers can add to their repo.</p>
]]></content:encoded></item><item><title><![CDATA[Guard your Vercel AI SDK and Claude Agent SDK tools in 5 minutes]]></title><description><![CDATA[Originally published on the Vulnify blog: https://www.vulnify.io/blog/guard-vercel-ai-sdk-claude-agent-sdk-tools

Your agent can call tools. That's the point. It's also the problem: the same exportCus]]></description><link>https://vulnify.hashnode.dev/guard-vercel-ai-sdk-claude-agent-sdk-tools</link><guid isPermaLink="true">https://vulnify.hashnode.dev/guard-vercel-ai-sdk-claude-agent-sdk-tools</guid><category><![CDATA[AI]]></category><category><![CDATA[Security]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[mcp]]></category><dc:creator><![CDATA[Vulnify]]></dc:creator><pubDate>Sat, 17 Oct 2026 01:08:11 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Originally published on the Vulnify blog: <a href="https://www.vulnify.io/blog/guard-vercel-ai-sdk-claude-agent-sdk-tools">https://www.vulnify.io/blog/guard-vercel-ai-sdk-claude-agent-sdk-tools</a></p>
</blockquote>
<p>Your agent can call tools. That's the point. It's also the problem: the same <code>exportCustomers</code> tool that helps a support agent answer a question can send 800 customer records to an outside address if the model misreads a prompt, or reads a poisoned one.</p>
<p>Prompts like "never export data without asking" are suggestions. The model can reason its way past them. The check has to sit on the tool call itself, before the side effect runs.</p>
<p>This post shows two small, runnable examples that do exactly that with <a href="https://www.vulnify.io/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=push-1016">Vulnify</a>: one for the Vercel AI SDK, one for the Claude Agent SDK. Both are in <a href="https://github.com/vulnify/vulnify-examples">vulnify/vulnify-examples</a>.</p>
<blockquote>
<p><strong>Honest note up front:</strong> neither example needs a model API key. The Vercel one uses the AI SDK's <code>MockLanguageModelV3</code> scripted to request the tools; the Claude one feeds <code>canUseTool</code> the exact tool requests Claude would send. <strong>The Vulnify checks are real API calls</strong> against a Vulnify org. Swap in a real model and the guard works the same way.</p>
</blockquote>
<h2>What Vulnify does, in one paragraph</h2>
<p>You describe each tool call as an action: what it does (<code>READ_DATA</code>, <code>WRITE_DATA</code>, <code>DELETE_DATA</code>, <code>EXPORT_DATA</code>, <code>SEND_EMAIL</code>), where the data goes (<code>INTERNAL</code>, <code>EXTERNAL_EMAIL</code>, <code>EXTERNAL_API</code>) and how many records it touches. Vulnify answers <strong>ALLOW</strong>, <strong>REVIEW</strong> or <strong>BLOCK</strong>, with a 0-100 risk score and the reasons. REVIEW waits for a person to approve in the Vulnify app or in Slack. Every decision lands in the audit log.</p>
<h2>Part 1: Vercel AI SDK (<code>guardAiSdkTools</code>)</h2>
<p>Two ordinary AI SDK tools. The <code>execute()</code> functions only log, so nothing leaves your machine:</p>
<pre><code class="language-js">import { tool } from 'ai';
import { z } from 'zod';

export const executed = [];

const rawTools = {
  lookupCustomer: tool({
    description: 'Read one customer record',
    inputSchema: z.object({ email: z.string() }),
    execute: async ({ email }) =&gt; { executed.push(`lookupCustomer ${email}`); return { email, plan: 'TEAM' }; },
  }),
  exportCustomers: tool({
    description: 'Export customer records to an email address',
    inputSchema: z.object({ to: z.string(), count: z.number() }),
    execute: async ({ to, count }) =&gt; { executed.push(`exportCustomers ${count} -&gt; ${to}`); return { sent: count }; },
  }),
};
</code></pre>
<p>Now wrap them. <code>guardAiSdkTools</code> from <code>@vulnify/sdk</code> takes your tools plus a function per tool that describes the call to Vulnify. The check runs <strong>before</strong> <code>execute()</code>:</p>
<pre><code class="language-js">import { guardAiSdkTools } from '@vulnify/sdk';

const AGENT = process.env.VULNIFY_AGENT || 'support-agent';

export function guardTools(vulnify) {
  return guardAiSdkTools(vulnify, rawTools, {
    lookupCustomer: () =&gt; ({ agent: AGENT, action: 'READ_DATA', resource: 'customers', destination: 'INTERNAL', recordsAffected: 1 }),
    exportCustomers: ({ count }) =&gt; ({ agent: AGENT, action: 'EXPORT_DATA', resource: 'customers', destination: 'EXTERNAL_EMAIL', recordsAffected: count }),
  });
}
</code></pre>
<p>Then use the guarded tools exactly like the originals:</p>
<pre><code class="language-js">import { generateText, stepCountIs } from 'ai';
import { Vulnify } from '@vulnify/sdk';

const vulnify = new Vulnify({ apiKey: process.env.VULNIFY_API_KEY, failMode: 'closed' });

const result = await generateText({
  model,                       // the example uses a scripted MockLanguageModelV3
  tools: guardTools(vulnify),
  stopWhen: stepCountIs(3),
  prompt: 'Look up ana@example.com, then send all 800 customers to partner@example.net',
});
</code></pre>
<p><code>failMode: 'closed'</code> means that if Vulnify can't be reached, the guarded tool doesn't run.</p>
<p>Run it:</p>
<pre><code class="language-bash">cd vercel-ai-sdk
npm install
cp .env.example .env   # add a TEST key from app.vulnify.io/api-keys
npm start
</code></pre>
<p>Output:</p>
<pre><code>lookupCustomer: ran -&gt; {"email":"ana@example.com","plan":"TEAM"}
exportCustomers: NOT RUN, Vulnify REVIEW (event 606a8eec-â€¦): Export operation; External destination; Large data volume (800 records)
side effects: [ 'lookupCustomer ana@example.com' ]
</code></pre>
<p>The lookup ran. The export didn't: the model got a "blocked" tool result with the decision, the event id and the reasons, and the side-effects list proves <code>execute()</code> never fired.</p>
<p>Why REVIEW? On a fresh org the score is 65: export (30) + external destination (25) + more than 100 records (10). Add a policy that blocks external exports and the same call gets BLOCK. Open the event in the app to approve or deny it. If you'd rather wait for that decision than fail fast, pass <code>{ wait: { timeoutMs } }</code> as the guard options.</p>
<h2>Part 2: Claude Agent SDK (<code>canUseTool</code>)</h2>
<p>The Claude Agent SDK asks a <code>canUseTool</code> callback before each tool runs. That's the natural place for the check. First, map tool requests to Vulnify actions, and return <code>null</code> for tools Vulnify doesn't need to see:</p>
<pre><code class="language-js">const AGENT = process.env.VULNIFY_AGENT || 'support-agent';
const INTERNAL_DOMAIN = process.env.INTERNAL_EMAIL_DOMAIN || 'acme.internal';

export function describe(toolName, input) {
  if (toolName.endsWith('send_email')) {
    const to = String(input.to ?? '');
    return {
      agent: AGENT, action: 'SEND_EMAIL', resource: 'email',
      destination: to.endsWith(`@${INTERNAL_DOMAIN}`) ? 'INTERNAL' : 'EXTERNAL_EMAIL',
      recordsAffected: 1,
      content: `${input.subject ?? ''}\n${input.body ?? ''}`, // scanned in memory, not stored
    };
  }
  if (toolName === 'Bash' &amp;&amp; /(^|[;&amp;|]\s*)rm\s/.test(String(input.command ?? ''))) {
    return { agent: AGENT, action: 'DELETE_DATA', resource: 'local files', destination: 'INTERNAL', recordsAffected: 1 };
  }
  return null;
}
</code></pre>
<p>Then the callback itself:</p>
<pre><code class="language-js">export function vulnifyCanUseTool(vulnify) {
  return async (toolName, input) =&gt; {
    const action = describe(toolName, input);
    if (!action) return { behavior: 'allow', updatedInput: input };
    const d = await vulnify.check(action);
    const decision = d.finalDecision ?? d.decision;
    if (decision === 'ALLOW') return { behavior: 'allow', updatedInput: input };
    return { behavior: 'deny', message: `Vulnify ${decision} (event ${d.id}): ${d.reasons.join('; ')}. Do not retry; ask the user.` };
  };
}
</code></pre>
<p>On a deny, Claude gets the reasons and the event id, and an explicit "ask the user" instead of a silent failure.</p>
<p><code>npm start</code> runs four scripted requests through it:</p>
<pre><code>mcp__mail__send_email team@acme.internal: ALLOW
mcp__mail__send_email someone@gmail.com: DENY - Vulnify REVIEW (event â€¦): Write operation; Sensitive data detected in content: CPF, EMAIL; External destination; â€¦
Bash rm -rf ./exports/customers: ALLOW
Read README.md: ALLOW
</code></pre>
<p>The internal email goes through. The external one contains a CPF and an email address, so it goes to REVIEW. The <code>rm</code> is allowed on a fresh org, because deleting local files scores below the REVIEW threshold. If you want every delete gated, add a policy in the app (for example, <code>DELETE_DATA</code> goes to REVIEW). <code>Read</code> never calls Vulnify at all.</p>
<p><code>npm run real</code> wires the same callback into <code>query()</code> from <code>@anthropic-ai/claude-agent-sdk</code>. That path needs your own <code>ANTHROPIC_API_KEY</code> and Claude Code, and it wasn't part of our live test.</p>
<h2>What happens after REVIEW</h2>
<p>A REVIEW isn't a dead end. The event shows up in the Vulnify app with the score and reasons, and a teammate can approve or deny it there or from Slack. Whatever they choose is recorded, so later you can answer "who let the agent do that?" from the audit trail, and download it as JSON or CSV with a SHA-256 checksum.</p>
<h2>Try it yourself</h2>
<p>Both examples take about two minutes and ship with an offline test (<code>npm test</code>, no key needed):</p>
<ul>
<li>Code: <a href="https://github.com/vulnify/vulnify-examples">github.com/vulnify/vulnify-examples</a></li>
<li>Free plan, no card: <a href="https://app.vulnify.io/setup?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=push-1016">create an account and a TEST key</a></li>
<li>Not on these SDKs? You can also connect Claude Code or Cursor in about two minutes from the same setup page.</li>
</ul>
<p>Use a TEST key, keep it in <code>.env</code>, and set <code>VULNIFY_AGENT</code> to an agent that already exists in your org (new names register on first use and count toward your plan's agent limit).</p>
<p>If you run it and something's unclear or broken, tell me in the comments. That's the feedback I'm after.</p>
]]></content:encoded></item><item><title><![CDATA[How to stop your AI agent from exporting customer data]]></title><description><![CDATA[Originally published on DEV: https://dev.to/vulnify/how-to-stop-your-ai-agent-from-exporting-customer-data-1dld
I'm Giovanni, founder of Vulnify, the tool used in the examples below. I checked every c]]></description><link>https://vulnify.hashnode.dev/how-to-stop-your-ai-agent-from-exporting-customer-data</link><guid isPermaLink="true">https://vulnify.hashnode.dev/how-to-stop-your-ai-agent-from-exporting-customer-data</guid><category><![CDATA[AI]]></category><category><![CDATA[Security]]></category><category><![CDATA[Python]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[langchain]]></category><dc:creator><![CDATA[Vulnify]]></dc:creator><pubDate>Fri, 16 Oct 2026 14:37:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aca6b6a3655f4d89686582f/b26e08b9-0214-4943-8b45-066dfa98c718.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Originally published on DEV: <a href="https://dev.to/vulnify/how-to-stop-your-ai-agent-from-exporting-customer-data-1dld">https://dev.to/vulnify/how-to-stop-your-ai-agent-from-exporting-customer-data-1dld</a></p>
<p><em>I'm Giovanni, founder of Vulnify, the tool used in the examples below. I checked every code sample and value against our docs and a real test organization. The pattern works without Vulnify, and I say where it doesn't fit.</em></p>
<p>An agent with a database tool can read one customer record. With the same tool and the same credential, it can export 12,000 of them to an outside email address.</p>
<p>The credential check passes both times. The question that matters is a different one: should this action, with this volume, to this destination, run right now?</p>
<p>This post is about answering that question in code, before the tool runs.</p>
<h2>Where the usual controls stop</h2>
<p>Most controls were built for people and for code, not for an agent acting at runtime.</p>
<ul>
<li><p>Code and prompt scanning happens before deploy. It never sees the actual call.</p>
</li>
<li><p>Identity grants a scope once. It doesn't look at each action.</p>
</li>
<li><p>Logs tell you afterwards. By then the export has already left.</p>
</li>
</ul>
<p>None of these is wrong. There is a gap between them, at the moment the agent acts. That's where a decision belongs.</p>
<h2>The pattern: describe, decide, act</h2>
<p>Before a sensitive tool runs, your code does three things.</p>
<ol>
<li><p><strong>Describe the action.</strong> Not the data. Which agent, which action, which resource, where it goes, how many records.</p>
</li>
<li><p><strong>Ask for a decision.</strong> One of three answers: ALLOW, REVIEW or BLOCK.</p>
</li>
<li><p><strong>Act only on ALLOW.</strong> REVIEW means a person has to approve first. BLOCK means it never starts.</p>
</li>
</ol>
<p>You can build this yourself. A function that takes the description, applies a few rules and returns a verdict will get you a long way. The hard parts come later: approvals by a second person, a record you can show someone, rolling out rules without breaking agents that already work. That's the part Vulnify handles.</p>
<h2>A real example in Node.js</h2>
<p>The SDKs are public: <code>@vulnify/sdk</code> on npm (0.3.3, Node.js 18+) and <code>vulnify</code> on PyPI (0.4.2, Python 3.9+). Both are MIT licensed.</p>
<pre><code class="language-bash">npm install @vulnify/sdk
</code></pre>
<p>This is the check from the docs Quickstart. <code>SalesBot</code> and <code>Customer Database</code> are example names. They have to be an agent and a resource registered in your organization.</p>
<pre><code class="language-typescript">import { Vulnify } from '@vulnify/sdk';

const vulnify = new Vulnify({ apiKey: process.env.VULNIFY_API_KEY! });

const decision = await vulnify.check({
  agent: 'SalesBot',
  action: 'EXPORT_DATA',
  resource: 'Customer Database',
  destination: 'EXTERNAL_EMAIL',
  recordsAffected: 12000,
});

if (decision.finalDecision === 'ALLOW') {
  await exportCustomerRecords();
} else if (decision.finalDecision === 'REVIEW') {
  // Stop. A person has to approve. decision.id is the event to poll.
} else {
  // BLOCK. The export does not run. decision.reasons says why.
}
</code></pre>
<p><code>action</code> is one of <code>READ_DATA</code>, <code>WRITE_DATA</code>, <code>DELETE_DATA</code>, <code>EXPORT_DATA</code> or <code>SEND_EMAIL</code>. <code>destination</code> is <code>INTERNAL</code>, <code>EXTERNAL_EMAIL</code> or <code>EXTERNAL_API</code>.</p>
<p>If you'd rather not write the branches, <code>guard()</code> wraps the function. It runs it only on ALLOW and throws <code>VulnifyBlockedError</code> on BLOCK and REVIEW.</p>
<pre><code class="language-typescript">await vulnify.guard(
  {
    agent: 'SalesBot',
    action: 'EXPORT_DATA',
    resource: 'Customer Database',
    destination: 'EXTERNAL_EMAIL',
    recordsAffected: 12000,
  },
  () =&gt; exportCustomerRecords(),
);
</code></pre>
<h2>The same check in Python</h2>
<pre><code class="language-bash">pip install vulnify
</code></pre>
<pre><code class="language-python">import os
from vulnify import Vulnify

vulnify = Vulnify(api_key=os.environ["VULNIFY_API_KEY"])

decision = vulnify.check(
    agent="SalesBot",
    action="EXPORT_DATA",
    resource="Customer Database",
    destination="EXTERNAL_EMAIL",
    records_affected=12000,
)

outcome = decision.final_decision if decision.final_decision is not None else decision.decision

if outcome == "ALLOW":
    export_customer_records()
</code></pre>
<p>The Python client keeps <code>final_decision</code> optional, so the docs fall back to <code>decision</code> when it is <code>None</code>. <code>guard()</code> does the same check around a callable:</p>
<pre><code class="language-python">vulnify.guard(
    lambda: export_customer_records(),
    agent="SalesBot",
    action="EXPORT_DATA",
    resource="Customer Database",
    destination="EXTERNAL_EMAIL",
    records_affected=12000,
)
</code></pre>
<p>The core Python package uses only the standard library.</p>
<h2>Inside an agent framework</h2>
<p>In a real agent you don't call <code>check()</code> by hand for every tool. You wrap the tool. The adapters ask for a <code>describe</code> function that maps tool arguments to an action, and they run the tool only on ALLOW.</p>
<p>Node.js has adapters for LangChain, MCP, the OpenAI Agents SDK and the Vercel AI SDK. Python has CrewAI and LangGraph. Here is LangGraph:</p>
<pre><code class="language-python">from langgraph.prebuilt import ToolNode
from vulnify.adapters import langgraph_tool_guard

guard = langgraph_tool_guard(
    vulnify,
    {
        "delete_ticket": lambda args: {
            "agent": "SupportBot",
            "action": "DELETE_DATA",
            "resource": "Support Tickets",
            "records_affected": 1,
        }
    },
)

graph.add_node("tools", ToolNode(tools, wrap_tool_call=guard))
</code></pre>
<p>Tools that aren't in the map run unchanged. A blocked call becomes an error tool message with the reasons, so the model can tell the user what happened instead of the graph failing halfway.</p>
<p>The <code>describe</code> function is where most of the thinking goes. For an export tool, it's where you decide that an address outside your company domain is <code>EXTERNAL_EMAIL</code>, and that the row count goes in <code>records_affected</code>.</p>
<h2>What a decision carries</h2>
<p>A verdict on its own isn't enough to trust. Every decision comes with a risk score from 0 to 100, a risk level and the reasons behind it.</p>
<p>Here is the export above, sent with a TEST key to our own test organization (example data, IDs left out):</p>
<pre><code class="language-json">{
  "decision": "BLOCK",
  "finalDecision": "BLOCK",
  "evaluatedDecision": "BLOCK",
  "riskLevel": "CRITICAL",
  "riskScore": 100,
  "reasons": [
    "Export operation",
    "Customer PII",
    "External destination",
    "Large data volume (12,000 records)"
  ],
  "policy": null
}
</code></pre>
<p>No policy matched. That decision came from the risk engine's defaults.</p>
<p>For contrast, a support agent reading one record from the same resource, to an internal destination, came back ALLOW with a score of 30 (MEDIUM). The reasons were "Read operation" and "Customer PII". It still touches personal data, so the score isn't zero. It just doesn't cross the line.</p>
<p>The score is rule-based, and I want to be clear about that. The point isn't that the number is always right. The point is that a person can read the reasons and disagree with them.</p>
<p>Policies sit on top of the score. A rule can match on the action, the resource or whether it holds sensitive data, the destination, a minimum or maximum record count, a minimum score, specific agents, and outside business hours. It can force REVIEW or BLOCK, or explicitly allow. Policies can live in YAML and be tested in CI with the <code>vulnify</code> CLI.</p>
<h2>REVIEW without stopping everything</h2>
<p>REVIEW means the action doesn't run yet. A person with an approver role approves or denies it in Vulnify, or the review expires. The process that called <code>check()</code> doesn't approve anything itself.</p>
<p>Two fields matter here. <code>decision</code> is what was recorded and stays <code>REVIEW</code>. <code>finalDecision</code> is what you act on: <code>REVIEW</code> while pending, <code>ALLOW</code> after approval, <code>BLOCK</code> after a denial or expiry.</p>
<p>By default, don't poll. Tell the caller a person has to approve, and leave the action unrun. Only that action waits. If you do want the process to wait, <code>guard()</code> takes a wait option:</p>
<pre><code class="language-typescript">await vulnify.guard(action, () =&gt; exportCustomerRecords(), {
  timeoutMs: 120_000,
  pollMs: 2000,
});
</code></pre>
<h2>Monitor mode first</h2>
<p>A new control in the critical path has to start quietly. If the first thing it does is block an agent that worked yesterday, it gets switched off.</p>
<p>In monitor mode, Vulnify scores every action and records what it would have blocked or sent to review, and answers ALLOW. Nothing stops. Each event keeps both answers.</p>
<p>Here is the same 12,000-record export with our test organization in monitor mode:</p>
<ul>
<li><p><code>evaluatedDecision</code>: BLOCK</p>
</li>
<li><p><code>finalDecision</code>: ALLOW</p>
</li>
<li><p><code>monitored</code>: true</p>
</li>
<li><p>the same four reasons, plus "Monitor mode: not enforced"</p>
</li>
</ul>
<p>Your code keeps following <code>finalDecision</code>, so nothing changes in behavior. You read the events, see which actions would have been stopped, tune the rules, and enforce when the team is ready. Monitor mode can be on for the whole organization, or per policy.</p>
<h2>Fail closed by default</h2>
<p>If Vulnify can't be reached (a timeout, a network error, a 429 or a 5xx, after retries), the SDK returns BLOCK with <code>degraded: true</code> and a reason that starts with "Vulnify unavailable". The action doesn't run.</p>
<p>Failing open is a setting you choose (<code>failMode: 'open'</code>, or <code>fail_mode="open"</code> in Python). The result is still marked as degraded, so you know which path it took. An invalid key, an unknown agent or a rejected payload still throws. Fail-open doesn't turn those into ALLOW.</p>
<h2>The audit trail</h2>
<p>Every decision, including human reviews, is written to the audit log when it's made, before your code runs the action. Each entry is appended to a per-organization SHA-256 hash chain. Change one entry and every hash after it stops matching. In the app, "Verify audit chain" recomputes the chain and reports the first entry that doesn't match.</p>
<p>The records hold action metadata: agent, action, resource, destination, record counts, timestamps, risk results and review decisions. Not the customer records themselves. If you send optional <code>content</code> for scanning, it's scanned in memory and not stored.</p>
<p>An owner or admin can download recent security events as a JSON or CSV download that includes a SHA-256 checksum. There is also a SIEM export: JSON or CEF lines.</p>
<p>A verified chain means the stored entries still hash together. It's evidence you can check. It's not a certification.</p>
<h2>Limitations, and when not to use this</h2>
<ul>
<li><p><strong>It only sees what you describe.</strong> If a tool isn't wrapped, or your <code>describe</code> function says <code>INTERNAL</code> for an outside address, the decision is made on wrong inputs. The mapping is your code.</p>
</li>
<li><p><strong>It decides on metadata, not content.</strong> It knows 12,000 rows were requested, not which rows. Optional content scanning is one signal, not a full DLP.</p>
</li>
<li><p><strong>It's not a prompt filter.</strong> It doesn't look at the conversation. It decides when the agent acts.</p>
</li>
<li><p><strong>It doesn't replace least privilege.</strong> Give the agent the narrowest credential you can. This is a second check on top, not a substitute.</p>
</li>
<li><p><strong>It's one more network call before the action.</strong> I haven't published latency numbers. Measure it in your own path before you put it in front of anything hot.</p>
</li>
<li><p><strong>The decision service is hosted.</strong> The SDKs are open source. The decisions are made on Vulnify's side, so the call leaves your network.</p>
</li>
<li><p><strong>Fail closed has a cost.</strong> If the service is unreachable, guarded actions stop. That's the point, and it's also an outage you'd feel.</p>
</li>
<li><p><strong>The score is rule-based.</strong> It doesn't guarantee every harmful action is caught or every allowed one is harmless.</p>
</li>
</ul>
<p>When not to use it: for read-only tools on public data, where a wrong call costs nothing. For a single script you run by hand. Or when your agent has no tool that can move, change or delete anything that matters. A decision layer earns its place where one action can do real damage.</p>
<hr />
<p>Comments are open here and on DEV. I'd like to hear one thing: which agent action would you want a decision on first?</p>
<p>Docs: <a href="https://docs.vulnify.io/quickstart/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=article-01">Quickstart</a> · Plans: <a href="https://www.vulnify.io/pricing?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=article-01">www.vulnify.io/pricing</a></p>
]]></content:encoded></item></channel></rss>