raxit
Frameworks

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.py

Run 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:

LangGraph docs/ops bot — governed zero-code

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.

langgraph_agent/tools.py
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:

langgraph_agent/tools.py
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:

.raxit/security.yaml
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.policydb.write matched the review rule. The call is suspended for human approval; with approvals wired up (or RAXIT_DEFER_TIMEOUT > 0) an operator can approve it via raxit approvals approve <token> and the call resumes. The demo runs with RAXIT_DEFER_TIMEOUT=0, so it surfaces the raw defer as RaxitDenied instead of blocking. See Approvals for the full defer → approve loop.
  • deny.default_or_forbidshell.exec appears nowhere in the policy, so it falls to default: block. The rm -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.

On this page