LangGraph (Python)
Governing LangGraph agents in Python — a real ToolNode demo with permit, defer, and deny.
LangGraph gets its own patcher because its tool-calling node dispatches tools differently from
a plain LangChain agent loop. raxit run hooks LangGraph's tool-execution point —
ToolNode._execute_tool_sync / _execute_tool_async on langgraph.prebuilt.tool_node.ToolNode
(falling back to _run_one / _arun_one on older LangGraph versions) — so every tool a
compiled graph dispatches is checked against policy before it runs, with zero governance
code in the agent. This page walks a real, executed demo: a docs/ops bot whose reads permit,
whose writes defer, and whose shell access denies.
Run it yourself
For your own LangGraph agent, install the SDK and launch it under raxit run:
pip install raxit
raxit run --agent-id my-agent -- python agent.pyRun the same file without raxit run and it runs ungoverned — governance is a deploy-time
choice, not a code change.
Here is a worked walkthrough, step by step, with output captured from a real run:
No raxit import anywhere. Ordinary StructuredTools dispatched by a real ToolNode — the only "wiring" is each tool's name, which is the policy resource it is governed as.
from langchain_core.tools import StructuredTool
def fetch_docs(query: str) -> str:
"""Search internal product documentation (read-only)."""
...
def db_write(table: str, payload: str) -> str:
"""Write a row to an internal datastore (requires human approval)."""
...
def build_tools():
return [
StructuredTool.from_function(fetch_docs, name="fetch_docs"),
StructuredTool.from_function(db_read, name="db.read"),
StructuredTool.from_function(db_write, name="db.write"),
StructuredTool.from_function(run_shell, name="shell.exec"),
]How it works
The agent's tools are ordinary StructuredTools wrapping plain functions, dispatched by a real
langgraph.prebuilt.ToolNode inside a compiled StateGraph. There is no raxit import in the
agent. The only "wiring" is each tool's name, which is the policy resource the auto-patcher
governs it as:
def build_tools() -> list[Any]:
return [
StructuredTool.from_function(fetch_docs, name="fetch_docs"),
StructuredTool.from_function(db_read, name="db.read"),
StructuredTool.from_function(db_write, name="db.write"),
StructuredTool.from_function(run_shell, name="shell.exec"),
]The policy is default-deny with one rule per effect:
apiVersion: raxit.security/v1
agent: langgraph-bot
default: block
rules:
- effect: allow
resource: fetch_docs
- effect: allow
resource: db.read
- effect: review # unconditional review == defer.policy
resource: db.write
# shell.exec is intentionally UNLISTED -> default-deny (deny.default_or_forbid).A ToolNode-dispatched tool is still, underneath, a langchain_core.tools.BaseTool — the same
class the LangChain (Python) patcher hooks via
.run/.arun/.invoke/.ainvoke. Without coordination, a call routed through ToolNode
would get governed twice. raxit avoids that with a shared depth-guard between the two patchers:
the langgraph patcher governs the call once, at the ToolNode layer, and the guard
suppresses the nested BaseTool.run call from being governed a second time. Both patchers are
active under raxit run — the demo's transcript shows exactly that:
[PASS] autopatch: install() governed the LangGraph ToolNode dispatch — patched={'langchain': ['run', 'arun', 'invoke', 'ainvoke'], 'langgraph': ['_execute_tool_sync', '_execute_tool_async']}A denied or deferred call raises raxit.autopatch.RaxitDenied from inside the node, carrying
the reason code — the tool body never executes. The demo proves that by observation: each tool
appends to the demo's call log, and the checks assert ran=False for every blocked call.
Catch the exception at the call site, or let it propagate to your graph's own error handling,
same as any other tool exception. The [raxit BLOCKED] message format and raxit explain <call_id> workflow are identical to LangChain (Python).
Scope: no MODIFY at the ToolNode layer
The ToolNode execute layer gates the call before it runs (permit / defer / deny), but the
patched dispatch method receives a request wrapper, not the raw tool args — so
argument-rewriting (MODIFY) verdicts need the BaseTool-layer patcher one level down. A
LangGraph agent that calls tools directly through BaseTool (bypassing the graph) gets full
MODIFY support via the LangChain patcher; calls routed through ToolNode get permit / defer /
deny. This limit is surfaced in the demo and our test suite, not hidden.
Output governance needs JSON-serializable results
The real run also surfaced this warning: raxit: could not taint-record the result of fetch_docs (not JSON-serializable); output governance did not run for this call. Input-side
gating (permit / defer / deny) still applied to every call; only the output taint-recording
step was skipped for results raxit couldn't serialize. See Taint.
What got denied and why
Two reason codes fire in this demo, both asserted by the CI harness against the captured log:
defer.policy—db.writematched thereviewrule. The call is suspended for human approval; with approvals wired up (orRAXIT_DEFER_TIMEOUT> 0) an operator can approve it viaraxit approvals approve <token>and the call resumes. The demo runs withRAXIT_DEFER_TIMEOUT=0, so it surfaces the raw defer asRaxitDeniedinstead of blocking. See Approvals for the full defer → approve loop.deny.default_or_forbid—shell.execappears nowhere in the policy, so it falls todefault: block. Therm -rf /argument never reaches a shell; the transcript's call-log check confirms the tool body never ran.
Every decision — the two permits, the defer, and the deny — lands in one hash-chained,
Ed25519-signed Decision Provenance log, verified green at the end of the run with
client.audit.verify(signatures=True). See Audit for how the chain is
built and Decisions for the full permit / defer / deny vocabulary
and reason-code catalog.

