raxit
Frameworks

LangGraph.js

Governing LangGraph.js agents in TypeScript — a real ToolNode demo with permit and deny.

LangGraph.js dispatches tool calls through ToolNode.prototype.runTool(call, config, state) — deliberately not invoke/run on ToolNode, which only see the whole batch of tool calls as one opaque blob. runTool is the per-call interception point that receives the individual {name, args, id} before the tool body runs, the same shape as the Python LangGraph patcher's _execute_tool_sync. The preload patches it automatically under raxit run — no governance code in the agent.

Run it yourself

For your own LangGraph.js agent, install the SDK and launch it under raxit run:

npm install @raxitlabs/raxit
raxit run -- node agent.mjs

Run the same file without raxit run and it runs ungoverned — governance is a deploy-time choice, not a code change. See LangChain.js / LangGraph.js for how the Node --import preload itself is wired.

How it works

Tools are ordinary tool()-built StructuredTools dispatched by a real @langchain/langgraph/prebuilt ToolNode. There is no raxit import in the agent:

getting_started.mjs
// imports NOTHING from raxit
import { tool } from "@langchain/core/tools";
import { ToolNode } from "@langchain/langgraph/prebuilt";

const shell_exec = tool(async ({ cmd }) => `…`, {
  name: "shell.exec",
  schema: z.object({ cmd: z.string() }),
});
const toolNode = new ToolNode([shell_exec]);
raxit run -- node getting_started.mjs

The policy is the same .raxit/security.yaml shared with the Python side of this example:

.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 denied call throws RaxitDenied, propagated out of ToolNode.invoke()/app.invoke() as a rejected promise, carrying the reason code — the tool body never runs. Real captured output from a run:

raxit autopatch full demo · raw LangGraph.js agent · daemon=http://127.0.0.1:8425

  | raw LangGraph.js docs/ops agent · governance=ON (raxit run)
  |
  | [fetch_docs] OK
  | [shell.exec] BLOCKED -> RaxitDenied: [raxit BLOCKED] 'shell.exec' denied (reason: deny.default_or_forbid). The operation was not executed. Details: raxit explain <id>

PASS  governance active (RAXIT_AUTOLOAD propagated by raxit run)
PASS  fetch_docs -> permit (ran)
  shell.exec reason=deny.default_or_forbid
PASS  deny.default_or_forbid: shell.exec -> deny (BLOCKED, never ran)
  audit.verify -> ok=true signatures_verified=true signed_count=2 count=2
PASS  audit.verify: signed Decision Provenance chain verified green

ALL PASS

Scope: permit/deny only here, no defer or MODIFY

This TS example only asserts permit and deny — the same two verdicts our test suite proves live against the real ToolNode.prototype.runTool interception point. defer is not independently verified on this path (the Python side of this same example does prove defer on db.write). MODIFY isn't supported here either: runTool receives a request object, not raw tool args threaded to a rewrite point the same way StructuredTool.invoke is — argument-rewriting verdicts are governed one layer down, at the BaseTool layer covered by LangChain.js / LangGraph.js.

What got denied and why

  • deny.default_or_forbidshell.exec appears nowhere in the policy, so it falls to default: block. The tool body never ran.

Both decisions land in one hash-chained, Ed25519-signed Decision Provenance log, verified green with client.audit.verify({ signatures: true, pubkeys: [...] }). See Audit and Decisions for the full permit / defer / deny vocabulary and reason-code catalog.

On this page