ctrlrun-langchain
Gate a LangChain agent's tool calls with a CTRLRun policy, through LangChain's own
wrap_tool_call middleware hook.
- Supported kernel range:
ctrlrun>=0.12,<0.13 - Supported framework range:
langchain>=1.0,<2.0 - Primitive reused:
AgentMiddleware.wrap_tool_call, whose contract is "Intercept execution and control when the handler is called. You decide if the handler is called zero times (short-circuit), once (normal flow), or multiple times." Read 2026-09-16. - Framework shape: the framework hands over the call itself.
This is not the LangGraph adapter
ctrlrun-langgraph routes an APPROVE through interrupt(), reusing a human-in-the-loop
primitive. This is a different thing on a different surface: LangChain's middleware gives the
tool call itself to the middleware, so handler is the executor.
That closes the gap every observation-hook integration lives with. There is no separate outcome
report to arrive late, be swallowed, or never fire. What the tool did is what handler returned
or raised, in the same stack frame, and the receipt says so.
Three consequences, which are the reason to use this over a log-and-hope callback:
- A denial never reaches the tool.
handleris not called, and the model gets aToolMessagesaying the call was refused and which rule refused it. - Once stays once. The effect is reserved before
handlerruns and committed from its return, so two agents sharing a store cannot both execute the same effect key. - An unknown outcome stays unknown. Anything
handlerraises that is notNotExecutedleaves the effectAMBIGUOUS, and the next attempt is refused until a human resolves it, rather than being retried into a double charge.
You may not need this
@protect already covers any Python callable, including a LangChain tool, with no middleware
and no framework support at all. This buys one thing over it: the gate applies to every tool
the agent can reach, including tools you did not write and cannot decorate.
There is a third way in that is not an adapter at all: ctrlrun gateway puts the same
guarantees in front of an MCP tool server, in any language, with no agent change.
Install
$ pip install ctrlrun-langchain
Use
The operator wires it, on the line where the policy, the store and the identity provider are
chosen. This middleware never constructs a Control (SPEC-v0.5 §2.3), so everything it must not
decide — the identity provider, the authority document, the environment, the mode — is chosen by
the person deploying it.
from langchain.agents import create_agent
from ctrlrun import Control
from ctrlrun_langchain import CTRLRunMiddleware
control = Control.from_file("ctrlrun.yaml")
agent = create_agent(
model="gpt-5.5",
tools=[lookup, issue_refund],
middleware=[CTRLRunMiddleware(control)],
)
With a policy that says refunds up to €50 are autonomous and the rest are denied:
schema: ctrlrun.policy/v2
actions:
lookup:
decision: allow
issue_refund:
effect: "refund:{payment_id}"
rules:
- when: { amount_gte: 0, amount_lte: 5000 }
decision: allow
- decision: deny
the agent's own tool calls are decided before they run:
lookup the tool runs
issue_refund amount=900000 CTRLRun refused this call: rule[1]. The tool did not run.
rm_rf CTRLRun refused this call: unknown_action. The tool did not run.
issue_refund amount=1000 the tool runs
issue_refund amount=1000 (again) CTRLRun refused this call: this effect is already committed
Nothing is default-allow: a tool the policy does not name is refused, which is why rm_rf above
never reaches handler.
Every protected call needs a principal. In production that is an identity provider that
verifies a credential; in development it is with ctrlrun.context(agent="support-agent"):
around the agent invocation. Without one the action is denied before the policy is consulted:
ActionDenied: lookup: no principal is available; wrap the call in
'with ctrlrun.context(agent=...)', or install an identity provider that answers
That is fail-closed and deliberate: who is acting is an authorization input, and a library that accepted a self-asserted principal would be accepting the agent's word for its own authority.
Approvals
Where the policy says approve, this middleware refuses the call and tells the model the
request id, rather than blocking the agent while a human deliberates:
CTRLRun is holding this call for a human. Approve it with 'ctrlrun approve apr_...',
then ask again. The tool did not run.
If you want the human answered inside the run instead, that is what ctrlrun-langgraph is
for: LangGraph's interrupt() suspends the graph, and the resumed run re-presents the same
proposal under the granted approval.
What this does not do
- It does not decide anything. The policy does, and the policy is the operator's file.
- It does not grant approvals.
InterruptApprovalProvider,ctrlrun approveand the webhook are the only places a grant is written, and this is not one of them. - It does not supply a principal, and it never reads one from agent state.
Apache-2.0, same as the kernel.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ctrlrun_langchain-1.0.0.tar.gz.
File metadata
- Download URL: ctrlrun_langchain-1.0.0.tar.gz
- Upload date:
- Size: 6.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c07505ead64dd5596895fac7f12fd6a0f3e5575b7300cf8f0febf989b651fcc9
|
|
| MD5 |
0de16164f77b029a01edef7ab2916c73
|
|
| BLAKE2b-256 |
4fd757ecd54d6e9d82c499bc324cff69a8b0b11e50e57b55c4628f5c3281517d
|
Provenance
The following attestation bundles were made for ctrlrun_langchain-1.0.0.tar.gz:
Publisher:
publish.yml on CTRLRun/ctrlrun
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ctrlrun_langchain-1.0.0.tar.gz -
Subject digest:
c07505ead64dd5596895fac7f12fd6a0f3e5575b7300cf8f0febf989b651fcc9 - Sigstore transparency entry: 2853053439
- Sigstore integration time:
-
Permalink:
CTRLRun/ctrlrun@a1f454d3294d0b9836fe18f0e983509b749c0aed -
Branch / Tag:
refs/tags/adapters-langchain-1.0.0 - Owner: https://github.com/CTRLRun
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a1f454d3294d0b9836fe18f0e983509b749c0aed -
Trigger Event:
push
-
Statement type:
File details
Details for the file ctrlrun_langchain-1.0.0-py3-none-any.whl.
File metadata
- Download URL: ctrlrun_langchain-1.0.0-py3-none-any.whl
- Upload date:
- Size: 7.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b99b0a9671906974fd5c9f7be909b869da2c46383a262ee3dab50b406788ed06
|
|
| MD5 |
1c9ad445970ed1ce895660d6690b2eea
|
|
| BLAKE2b-256 |
ad0783d3db47a46d43d013c4d5638e4343a4c009ada631008232796297af3c5a
|
Provenance
The following attestation bundles were made for ctrlrun_langchain-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on CTRLRun/ctrlrun
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ctrlrun_langchain-1.0.0-py3-none-any.whl -
Subject digest:
b99b0a9671906974fd5c9f7be909b869da2c46383a262ee3dab50b406788ed06 - Sigstore transparency entry: 2853053513
- Sigstore integration time:
-
Permalink:
CTRLRun/ctrlrun@a1f454d3294d0b9836fe18f0e983509b749c0aed -
Branch / Tag:
refs/tags/adapters-langchain-1.0.0 - Owner: https://github.com/CTRLRun
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a1f454d3294d0b9836fe18f0e983509b749c0aed -
Trigger Event:
push
-
Statement type: