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.pyThe demo, step by step
The playback below pairs each moment with the real code that produced it and the real output it produced:
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.)
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.payoutsHow 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:
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.payoutsThe 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:
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 invoke → run 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_forbidHow 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_forbid—create_payout/stripe.payoutshit the expliciteffect: blockrule. The same code fires when a call matches no rule underdefault: 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 forraxit explain.defer.policy—refund_charge/stripe.refundmatchedeffect: review, so the engine returned defer. The demo pinsRAXIT_DEFER_TIMEOUT=0to show the raw[raxit PENDING] … (reason: defer.policy)refusal; with a real timeout, the call blocks while a human runsraxit 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.
Related
- 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: blockand engine errors both land on deny - LangGraph (Python) and LangChain.js for the sibling patchers

