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.
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.
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?
This post is about answering that question in code, before the tool runs.
Where the usual controls stop
Most controls were built for people and for code, not for an agent acting at runtime.
- Code and prompt scanning happens before deploy. It never sees the actual call.
- Identity grants a scope once. It doesn't look at each action.
- Logs tell you afterwards. By then the export has already left.
None of these is wrong. There is a gap between them, at the moment the agent acts. That's where a decision belongs.
The pattern: describe, decide, act
Before a sensitive tool runs, your code does three things.
- Describe the action. Not the data. Which agent, which action, which resource, where it goes, how many records.
- Ask for a decision. One of three answers: ALLOW, REVIEW or BLOCK.
- Act only on ALLOW. REVIEW means a person has to approve first. BLOCK means it never starts.
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.
A real example in Node.js
The SDKs are public: @vulnify/sdk on npm (0.3.3, Node.js 18+) and vulnify on PyPI (0.4.2, Python 3.9+). Both are MIT licensed.
npm install @vulnify/sdk
This is the check from the docs Quickstart. SalesBot and Customer Database are example names. They have to be an agent and a resource registered in your organization.
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.
}
action is one of READ_DATA, WRITE_DATA, DELETE_DATA, EXPORT_DATA or SEND_EMAIL. destination is INTERNAL, EXTERNAL_EMAIL or EXTERNAL_API.
If you'd rather not write the branches, guard() wraps the function. It runs it only on ALLOW and throws VulnifyBlockedError on BLOCK and REVIEW.
await vulnify.guard(
{
agent: 'SalesBot',
action: 'EXPORT_DATA',
resource: 'Customer Database',
destination: 'EXTERNAL_EMAIL',
recordsAffected: 12000,
},
() => exportCustomerRecords(),
);
The same check in Python
pip install vulnify
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()
The Python client keeps final_decision optional, so the docs fall back to decision when it is None. guard() does the same check around a callable:
vulnify.guard(
lambda: export_customer_records(),
agent="SalesBot",
action="EXPORT_DATA",
resource="Customer Database",
destination="EXTERNAL_EMAIL",
records_affected=12000,
)
The core Python package uses only the standard library.
Inside an agent framework
In a real agent you don't call check() by hand for every tool. You wrap the tool. The adapters ask for a describe function that maps tool arguments to an action, and they run the tool only on ALLOW.
Node.js has adapters for LangChain, MCP, the OpenAI Agents SDK and the Vercel AI SDK. Python has CrewAI and LangGraph. Here is LangGraph:
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))
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.
The describe 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 EXTERNAL_EMAIL, and that the row count goes in records_affected.
What a decision carries
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.
Here is the export above, sent with a TEST key to our own test organization (example data, IDs left out):
{
"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
}
No policy matched. That decision came from the risk engine's defaults.
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.
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.
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 vulnify CLI.
REVIEW without stopping everything
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 check() doesn't approve anything itself.
Two fields matter here. decision is what was recorded and stays REVIEW. finalDecision is what you act on: REVIEW while pending, ALLOW after approval, BLOCK after a denial or expiry.
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, guard() takes a wait option:
await vulnify.guard(action, () => exportCustomerRecords(), {
timeoutMs: 120_000,
pollMs: 2000,
});
Monitor mode first
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.
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.
Here is the same 12,000-record export with our test organization in monitor mode:
-
evaluatedDecision: BLOCK -
finalDecision: ALLOW -
monitored: true - the same four reasons, plus "Monitor mode: not enforced"
Your code keeps following finalDecision, 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.
Fail closed by default
If Vulnify can't be reached (a timeout, a network error, a 429 or a 5xx, after retries), the SDK returns BLOCK with degraded: true and a reason that starts with "Vulnify unavailable". The action doesn't run.
Failing open is a setting you choose (failMode: 'open', or fail_mode="open" 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.
The audit trail
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.
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 content for scanning, it's scanned in memory and not stored.
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.
A verified chain means the stored entries still hash together. It's evidence you can check. It's not a certification.
Limitations, and when not to use this
-
It only sees what you describe. If a tool isn't wrapped, or your
describefunction saysINTERNALfor an outside address, the decision is made on wrong inputs. The mapping is your code. - It decides on metadata, not content. It knows 12,000 rows were requested, not which rows. Optional content scanning is one signal, not a full DLP.
- It's not a prompt filter. It doesn't look at the conversation. It decides when the agent acts.
- It doesn't replace least privilege. Give the agent the narrowest credential you can. This is a second check on top, not a substitute.
- It's one more network call before the action. I haven't published latency numbers. Measure it in your own path before you put it in front of anything hot.
- The decision service is hosted. The SDKs are open source. The decisions are made on Vulnify's side, so the call leaves your network.
- Fail closed has a cost. If the service is unreachable, guarded actions stop. That's the point, and it's also an outage you'd feel.
- The score is rule-based. It doesn't guarantee every harmful action is caught or every allowed one is harmless.
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.
I'd like to hear one thing in the comments: which agent action would you want a decision on first?
Docs: Quickstart ยท Plans: www.vulnify.io/pricing
Top comments (0)