Blog

Vulnify for LangChain and LangGraph: approve risky tool calls

· 5 min read

Vulnify blog cover: Vulnify for LangChain and LangGraph: approve risky tool calls

A LangChain or LangGraph agent picks its own tools. Most of those calls are harmless reads. A few delete records or send data out of the company, and those are the ones a person should see before they run.

This post shows how to put one check in front of those few tools, without changing the shape of your graph. Reads run as before. A delete is blocked. An export to an outside address waits for an approver, then runs or not.

Why not just use interrupt()?

LangGraph's interrupt() can pause a graph, and for a single, well-known step it's a fine tool. The trouble starts when you want it as a general approval rule:

  • You write the bookkeeping. Which calls pause, who is allowed to resume them, and where the answer is recorded are all your code.
  • Parallel tool calls get messy. When the model asks for several tools in one step, you have to track which of them are waiting and which already ran.
  • The rule lives in the graph. Changing "exports over 500 rows need approval" means changing and redeploying the agent.

The alternative is to ask a separate decision service before each sensitive tool runs, and keep the rules there.

How the check works

langgraph_tool_guard from the Vulnify Python SDK is a wrap_tool_call for LangGraph's ToolNode. For each tool you list, it turns the tool call's arguments into an action (agent, action, resource, destination, record count) and asks Vulnify. Vulnify answers ALLOW, REVIEW or BLOCK, with a risk score from 0 to 100 and the reasons.

The tool runs only when the final decision is ALLOW. Otherwise the model gets an error tool message with Vulnify's reasons, and the tool doesn't run. Tools you don't list never call Vulnify at all.

pip install vulnify langgraph langchain

Guard the tool node

You describe what each sensitive tool really does. That's the part only your code knows: an email address becomes a destination, a rows argument becomes a record count.

import os

from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode
from vulnify import Vulnify
from vulnify.adapters import langgraph_tool_guard

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


@tool
def delete_ticket(ticket_id: str) -> str:
    """Delete a support ticket."""
    return f"deleted {ticket_id}"  # your real delete; runs only after ALLOW


@tool
def export_tickets(email: str, rows: int) -> str:
    """Email a CSV of support tickets."""
    return f"sent {rows} tickets to {email}"  # runs only after ALLOW


def describe_delete(args: dict) -> dict:
    return {
        "agent": "SupportBot",
        "action": "DELETE_DATA",
        "resource": "Support Tickets",
        "records_affected": 1,
    }


def describe_export(args: dict) -> dict:
    email = str(args.get("email", ""))
    return {
        "agent": "SupportBot",
        "action": "EXPORT_DATA",
        "resource": "Support Tickets",
        "destination": "INTERNAL" if email.endswith("@yourcompany.com") else "EXTERNAL_EMAIL",
        "records_affected": int(args.get("rows", 0)),
    }


guard = langgraph_tool_guard(
    vulnify,
    {"delete_ticket": describe_delete, "export_tickets": describe_export},
    wait={"timeout": 120},  # on REVIEW, wait up to 2 minutes for a person
)

tool_node = ToolNode([delete_ticket, export_tickets], wrap_tool_call=guard)

Use tool_node in your graph as usual. For an async graph, pass awrap_tool_call=alanggraph_tool_guard(...) instead; it takes the same arguments.

Or use it as create_agent middleware

If you build agents with LangChain's create_agent, the same guard works as middleware:

from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call

agent = create_agent(model, tools=[delete_ticket, export_tickets], middleware=[wrap_tool_call(guard)])

We ran this against vulnify 0.4.2, langchain 1.4.7 and langgraph 1.2.17, with a stubbed model and a stubbed decision endpoint. When the decision was BLOCK, the delete tool never ran and the agent got this tool message, with status error:

Vulnify BLOCK: delete of support tickets requires approval

When the decision was ALLOW, the tool ran and returned deleted T-42. The text after Vulnify BLOCK: is whatever reasons the decision carries, so the model can explain to the user why it stopped.

The rules live in policies

Two rules cover this example: agents never delete support tickets, and an export that leaves the company waits for a person.

apiVersion: vulnify.io/v1
kind: Policy
metadata:
  name: block-ticket-deletes
spec:
  description: Agents never delete support tickets
  enabled: true
  action: DELETE
  resource: ANY
  decision: BLOCK
  mode: ENFORCE
---
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

You can apply these with the CLI or create the same rules in the app. Changing a rule doesn't touch the agent's code.

Two details worth knowing. A REVIEW policy can tighten the risk engine but never loosen it, so exporting data classified Sensitive or Customer PII to an outside destination is blocked outright even with this policy in place. And if you're not ready to block anything yet, set mode: MONITOR first: Vulnify records what would have been reviewed or blocked and lets the tool run.

What a review looks like

On a test workspace, an export of 800 rows to partner@example.com came back like this:

decision: REVIEW
riskLevel: HIGH
riskScore: 65
reasons: Export operation; External destination; Large data volume (800 records); Policy matched: review-external-exports

Because the guard was created with wait={"timeout": 120}, the tool node waits while an approver decides in Vulnify or in Slack. If they approve, the export runs and the model gets the tool's normal result. If they deny it, it expires, or nobody answers within 120 seconds, the export doesn't run and the model gets the reasons and the outcome.

Without wait, a REVIEW returns that message right away. Use that when the agent should move on and let the person decide later.

If Vulnify can't be reached, the SDK fails closed by default: the result is BLOCK, marked as degraded, and the tool doesn't run. Every decision, including the blocked ones, is appended to the audit log.

Before you start

  • Get an API key from app.vulnify.io and use a test key while you try this.
  • Register the agent (SupportBot) and the resource (Support Tickets) in the app under those names. An unknown agent or resource raises an error.
  • Give the agent permission for the actions it uses on that resource.

The full walkthrough, including a complete StateGraph, is in the LangGraph human approval guide, and the other Python adapters are in Framework adapters. For the data side of the same problem, see how to stop an AI agent from exfiltrating customer data.

Vulnify decides every sensitive agent action before it runs: ALLOW, REVIEW or BLOCK, with the reasons.

Try it free