raxit
Frameworks

LangChain (Python)

Governing LangChain agents in Python — permit, deny, defer, and modify from one security.yaml, shown with a real executed demo.

LangChain is raxit's reference patcher — the pattern every other framework's patcher follows. This page walks a payments-bot example: six mock Stripe tools governed two ways against the same security.yaml — explicit @governed_tool wrappers first, then the zero-code autopatch raxit run activates — ending with one Ed25519-signed audit chain over every decision. Every terminal line shown below is from a real run, not a mock-up.

Run it yourself

For your own LangChain agent, the shape is:

pip install raxit
raxit init            # scans your code, generates .raxit/security.yaml
raxit run --agent-id my-agent -- python agent.py

The demo, step by step

The playback below pairs each moment with the real code that produced it and the real output it produced:

LangChain payments-bot: permit · deny · modify · defer, one policy

The whole demo is governed by this security.yaml: default-deny, an amount-conditioned allow on charges, a review (defer) on refunds, a redact-on-send for email, and an explicit block on payouts. (One allow rule the walkthrough does not exercise, stripe.customers.read, is elided here.)

.raxit/security.yaml
agent: payments-bot
default: block
rules:
  - effect: allow
    resource: search_docs
  - effect: allow
    resource: stripe.charge
    when: amount_cents < 50000
  - effect: review              # review == defer (needs human approval)
    resource: stripe.charge
    when: amount_cents >= 50000
  - resource: send_email        # MODIFY: redact PII in the body before sending
    modify: { redact: [body] }
  - effect: review
    resource: stripe.refund
  - effect: block
    resource: stripe.payouts
terminal
$ make -C examples demo-langchain-governed-py
--- Scenario A: fine-grained @governed_tool ---

How it works

One policy file, .raxit/security.yaml, drives all four verdicts. default: block means anything not named is denied; effect: review maps to defer (human approval); a modify: rule redacts arguments before the tool body runs:

.raxit/security.yaml
apiVersion: raxit.security/v1
agent: payments-bot
default: block
rules:
  - effect: allow
    resource: search_docs
  - effect: allow
    resource: stripe.charge
    when: amount_cents < 50000
  - effect: review              # review == defer (needs human approval)
    resource: stripe.charge
    when: amount_cents >= 50000
  - resource: send_email        # MODIFY: redact PII in the body before sending
    modify: { redact: [body] }
  - effect: review
    resource: stripe.refund
  - effect: block
    resource: stripe.payouts

The demo enforces it through both governance styles raxit supports:

Fine-grained (@governed_tool). Each tool is explicitly wrapped, so the caller controls which policy resource each tool maps to. On a non-permit verdict the wrapper returns an honest refusal string ([raxit BLOCKED] … / [raxit PENDING] …) and the body never runs; on a MODIFY verdict the body runs on the engine's modified_args:

payments_agent/tools.py
from raxit import GovernanceClient
from raxit.agent import governed_tool as _raxit_governed_tool
from langchain_core.tools import tool

def governed_tool(resource: str, governance: GovernanceClient):
    def decorator(fn):
        return tool(_raxit_governed_tool(resource, governance)(fn))
    return decorator

@governed_tool("stripe.payouts", governance)
def create_payout(amount_cents: int, destination: str) -> str:
    """Initiate a payout from the platform balance to an external bank
    account. Policy forbids this for the payments-bot."""
    ...

Zero-code (autopatch). Scenario B's tools have no raxit import at all — plain StructuredTool.from_function(...) calls whose only "wiring" is that each tool's name is the policy resource. Autopatch monkeypatches all four dispatch methods on langchain_core.tools.BaseTool (run, arun, invoke, ainvoke), with a depth-guard so a call that goes invokerun is governed exactly once. Here a denied call surfaces as a raised raxit.autopatch.RaxitDenied exception instead of a refusal string:

from raxit.autopatch import RaxitDenied

try:
    tools["stripe.payouts"].invoke({"amount_cents": 5000, "destination": "acct_x"})
except RaxitDenied as exc:
    print(exc.reason_code)   # deny.default_or_forbid

How the demo proves the zero-code path

The demo calls raxit.autopatch.install() directly — the exact hook raxit run triggers via sitecustomize — so the two scenarios can share one process and one daemon deterministically. Launching the same plain-tools agent with raxit run --agent-id payments-bot -- python agent.py goes through the identical patched BaseTool dispatch. The demo's call logs (CHARGES / PAYOUTS / SENT) prove the denied payout body never executed and the email body was already redacted when the tool ran.

Both styles compose: autopatch governs everything by default; governed_tool is the escape hatch when you want to say something more specific about one tool.

What got denied and why

The run exercised two refusal reason codes (the log assertion at the end of the transcript checks both appeared):

  • deny.default_or_forbidcreate_payout / stripe.payouts hit the explicit effect: block rule. The same code fires when a call matches no rule under default: block — hence "default or forbid". The tool body never ran; the agent got [raxit BLOCKED] 'stripe.payouts' denied (reason: deny.default_or_forbid). The operation was not executed. plus a call id for raxit explain.
  • defer.policyrefund_charge / stripe.refund matched effect: review, so the engine returned defer. The demo pins RAXIT_DEFER_TIMEOUT=0 to show the raw [raxit PENDING] … (reason: defer.policy) refusal; with a real timeout, the call blocks while a human runs raxit approvals approve <token> — see Approvals for that flow.

Note what is not a refusal: the send_email MODIFY verdict. The email sent — but the engine rewrote body to card [REDACTED:pii_credit_card] before the tool ever saw it.

  • Decisions — the permit / defer / deny vocabulary and how MODIFY fits alongside it
  • Approvals — what happens after a defer.policy
  • Audit — the hash-chained, signed Decision Provenance log the finale verifies
  • Fail-closed — why default: block and engine errors both land on deny
  • LangGraph (Python) and LangChain.js for the sibling patchers

On this page