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.mjsRun 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:
// 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.mjsThe policy is the same .raxit/security.yaml shared with the Python side of this example:
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 PASSScope: 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_forbid—shell.execappears nowhere in the policy, so it falls todefault: 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.
Related
- LangGraph (Python) — the same example, same policy, full permit/defer/deny coverage on the Python side.
- LangChain.js / LangGraph.js — the
BaseToollayer patcher, which additionally covers MODIFY.

