ctrlrun-langchain 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ # SPDX-FileCopyrightText: 2026 The CTRLRun contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Gate a LangChain agent's tool calls with a CTRLRun policy, through `wrap_tool_call`.
4
+
5
+ **This is not the LangGraph adapter, and it is not an adapter at all in SPEC-v0.5 §2's sense.**
6
+ `ctrlrun-langgraph` exists to route an `APPROVE` through `interrupt()`; it reuses a framework's
7
+ human-in-the-loop primitive and contributes two lines. This is the other thing entirely: a
8
+ `wrap_tool_call` middleware, where the framework hands over the call itself.
9
+
10
+ The distinction matters because of what `wrap_tool_call` is. LangChain's own documentation:
11
+
12
+ Intercept execution and control when the handler is called. You decide if the handler is
13
+ called zero times (short-circuit), once (normal flow), or multiple times (retry logic).
14
+
15
+ So `handler` **is** the tool call. That makes it the executor `Control.execute` has always
16
+ wanted, and it closes the gap every observation-hook integration has to live with: there is no
17
+ separate outcome report to arrive late, be swallowed, or never fire. What the tool did is what
18
+ `handler` returned or raised, in the same stack frame, and the receipt says so.
19
+
20
+ Three consequences worth stating, because they are the reason to use this over a log-and-hope
21
+ callback:
22
+
23
+ - **A denial never reaches the tool.** The handler is not called, and the model gets a
24
+ `ToolMessage` saying the call was refused and why.
25
+ - **`once stays once` is real here.** The effect is reserved before `handler` runs and committed
26
+ from its return, so two agents sharing a store cannot both execute the same effect key.
27
+ - **An unknown outcome stays unknown.** If `handler` raises something that is not `NotExecuted`,
28
+ the effect is `AMBIGUOUS` and the next attempt is refused until a human resolves it, rather
29
+ than being retried into a double charge.
30
+
31
+ **You may not need this.** `@protect` already covers any Python callable, including a LangChain
32
+ tool, with no middleware and no framework support. This buys one thing: the gate applies to
33
+ *every* tool the agent can reach, including tools you did not write and cannot decorate.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections.abc import Callable, Mapping
39
+ from typing import Any, Final
40
+
41
+ from langchain.agents.middleware import AgentMiddleware
42
+
43
+ from ctrlrun import (
44
+ Action,
45
+ ActionDenied,
46
+ AmbiguousEffect,
47
+ ApprovalRequired,
48
+ Control,
49
+ DuplicateEffect,
50
+ )
51
+ from ctrlrun.effect import resolve_resource
52
+
53
+ __all__ = ["CTRLRunMiddleware"]
54
+
55
+ #: What the model is told when CTRLRun refuses. A refusal is the statement that the tool did
56
+ #: not run, which is not the same as the tool failing, so it names the rule rather than
57
+ #: reporting an error the tool never produced.
58
+ _REFUSED: Final = "CTRLRun refused this call: {reason}. The tool did not run."
59
+
60
+
61
+ def _tool_message(request: Any, content: str) -> Any:
62
+ """The refusal, in the shape LangChain's own limit middleware uses."""
63
+ from langchain_core.messages import ToolMessage
64
+
65
+ call = request.tool_call
66
+ return ToolMessage(
67
+ content=content,
68
+ tool_call_id=call["id"],
69
+ name=call.get("name"),
70
+ status="error",
71
+ )
72
+
73
+
74
+ class CTRLRunMiddleware(AgentMiddleware):
75
+ """`AgentMiddleware` that runs every tool call through a `Control`.
76
+
77
+ The **operator** constructs it, on the line where the policy, the store and the identity
78
+ provider are chosen. This class never constructs a `Control`: everything it must not
79
+ decide is decided by the person deploying it (SPEC-v0.5 §2.3).
80
+
81
+ control = Control(policy, store, identity=..., authority=...)
82
+ agent = create_agent(model, tools=[...], middleware=[CTRLRunMiddleware(control)])
83
+
84
+ `resource` and `effect` are templates over the tool's arguments, exactly as `@protect`'s
85
+ are, and the policy's own entries are used where none is given here.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ control: Control,
91
+ *,
92
+ resource: str | None = None,
93
+ effect: str | None = None,
94
+ task: str | None = None,
95
+ ) -> None:
96
+ super().__init__()
97
+ self._control = control
98
+ self._resource = resource
99
+ self._effect = effect
100
+ self._task = task
101
+
102
+ # -- the hook ---------------------------------------------------------------------
103
+
104
+ def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
105
+ """Decide, then run the handler as the executor, then record what it did."""
106
+ call = request.tool_call
107
+ name = call.get("name") or ""
108
+ arguments: Mapping[str, Any] = call.get("args") or {}
109
+
110
+ try:
111
+ action = self._action(name, arguments)
112
+ except Exception as exc: # a policy that cannot name this tool is a refusal
113
+ return _tool_message(request, _REFUSED.format(reason=f"could not be evaluated: {exc}"))
114
+
115
+ returned: list[Any] = []
116
+
117
+ def executor() -> Any:
118
+ # `handler` is the tool call. Its return value is the outcome, and anything it
119
+ # raises that is not `NotExecuted` leaves the effect AMBIGUOUS, which is the
120
+ # honest state for a call whose result nobody established.
121
+ result = handler(request)
122
+ returned.append(result)
123
+ return result
124
+
125
+ try:
126
+ self._control.execute(action, executor, self._effect_key(name, arguments))
127
+ except ActionDenied as denied:
128
+ return _tool_message(request, _REFUSED.format(reason=denied.reason))
129
+ except ApprovalRequired as pending:
130
+ return _tool_message(
131
+ request,
132
+ f"CTRLRun is holding this call for a human. Approve it with "
133
+ f"'ctrlrun approve {pending.request_id}', then ask again. The tool did not run.",
134
+ )
135
+ except DuplicateEffect as duplicate:
136
+ return _tool_message(
137
+ request,
138
+ f"CTRLRun refused this call: this effect is already {duplicate.state} "
139
+ f"({duplicate.effect_key}). The tool did not run.",
140
+ )
141
+ except AmbiguousEffect as ambiguous:
142
+ return _tool_message(
143
+ request,
144
+ f"CTRLRun refused this call: the outcome of {ambiguous.effect_key} was never "
145
+ f"established, so a retry is unsafe. Resolve it with "
146
+ f"'ctrlrun resolve {ambiguous.effect_key}'. The tool did not run.",
147
+ )
148
+
149
+ return returned[0] if returned else None
150
+
151
+ async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
152
+ """Async agents reach the same decision through the same `Control`.
153
+
154
+ Deliberately not a parallel implementation. `Control.execute` is synchronous and owns
155
+ the reservation, so a second async path would be a second place the once-only rule is
156
+ enforced, and a second place to get it wrong.
157
+ """
158
+ import anyio
159
+
160
+ result: list[Any] = []
161
+
162
+ def run() -> None:
163
+ result.append(self.wrap_tool_call(request, lambda r: anyio.from_thread.run(handler, r)))
164
+
165
+ await anyio.to_thread.run_sync(run)
166
+ return result[0]
167
+
168
+ # -- internals --------------------------------------------------------------------
169
+
170
+ def _action(self, name: str, arguments: Mapping[str, Any]) -> Action:
171
+ """The action this tool call proposes.
172
+
173
+ The principal comes from `Control.resolve_principal`, never from the agent's state: a
174
+ principal supplied by the caller is not an authorization input (SPEC-v0.3 §4.2).
175
+ """
176
+ principal = self._control.resolve_principal(name)
177
+ template = (
178
+ self._resource
179
+ if self._resource is not None
180
+ else (self._control.policy.resource_template(name))
181
+ )
182
+ return Action(
183
+ name=name,
184
+ arguments=dict(arguments),
185
+ principal=principal,
186
+ resource=None if template is None else resolve_resource(template, arguments),
187
+ environment=self._control.environment,
188
+ )
189
+
190
+ def _effect_key(self, name: str, arguments: Mapping[str, Any]) -> str | None:
191
+ template = (
192
+ self._effect
193
+ if self._effect is not None
194
+ else (self._control.policy.effect_template(name))
195
+ )
196
+ return None if template is None else resolve_resource(template, arguments)
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctrlrun-langchain
3
+ Version: 1.0.0
4
+ Summary: Gate a LangChain agent's tool calls with a CTRLRun policy, through wrap_tool_call.
5
+ Author-email: Arpan Ghoshal <contact@arpanghoshal.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/CTRLRun/ctrlrun
8
+ Project-URL: Repository, https://github.com/CTRLRun/ctrlrun
9
+ Keywords: langchain,ctrlrun,middleware,guardrails,agent,human-in-the-loop
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: ctrlrun<0.13,>=0.12
18
+ Requires-Dist: langchain<2.0,>=1.0
19
+
20
+ # ctrlrun-langchain
21
+
22
+ Gate a LangChain agent's tool calls with a CTRLRun policy, through **LangChain's own
23
+ `wrap_tool_call`** middleware hook.
24
+
25
+ - **Supported kernel range:** `ctrlrun>=0.12,<0.13`
26
+ - **Supported framework range:** `langchain>=1.0,<2.0`
27
+ - **Primitive reused:** [`AgentMiddleware.wrap_tool_call`](https://docs.langchain.com/oss/langchain/middleware/custom), 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.
28
+ - **Framework shape:** the framework hands over the call itself.
29
+
30
+ ## This is not the LangGraph adapter
31
+
32
+ `ctrlrun-langgraph` routes an `APPROVE` through `interrupt()`, reusing a human-in-the-loop
33
+ primitive. This is a different thing on a different surface: LangChain's middleware gives the
34
+ tool call itself to the middleware, so `handler` **is** the executor.
35
+
36
+ That closes the gap every observation-hook integration lives with. There is no separate outcome
37
+ report to arrive late, be swallowed, or never fire. What the tool did is what `handler` returned
38
+ or raised, in the same stack frame, and the receipt says so.
39
+
40
+ Three consequences, which are the reason to use this over a log-and-hope callback:
41
+
42
+ - **A denial never reaches the tool.** `handler` is not called, and the model gets a
43
+ `ToolMessage` saying the call was refused and which rule refused it.
44
+ - **Once stays once.** The effect is reserved before `handler` runs and committed from its
45
+ return, so two agents sharing a store cannot both execute the same effect key.
46
+ - **An unknown outcome stays unknown.** Anything `handler` raises that is not `NotExecuted`
47
+ leaves the effect `AMBIGUOUS`, and the next attempt is refused until a human resolves it,
48
+ rather than being retried into a double charge.
49
+
50
+ ## You may not need this
51
+
52
+ `@protect` already covers any Python callable, including a LangChain tool, with no middleware
53
+ and no framework support at all. This buys one thing over it: the gate applies to **every** tool
54
+ the agent can reach, including tools you did not write and cannot decorate.
55
+
56
+ There is a third way in that is not an adapter at all: `ctrlrun gateway` puts the same
57
+ guarantees in front of an MCP tool server, in any language, with no agent change.
58
+
59
+ ## Install
60
+
61
+ ```console
62
+ $ pip install ctrlrun-langchain
63
+ ```
64
+
65
+ ## Use
66
+
67
+ The **operator** wires it, on the line where the policy, the store and the identity provider are
68
+ chosen. This middleware never constructs a `Control` (SPEC-v0.5 §2.3), so everything it must not
69
+ decide — the identity provider, the authority document, the environment, the mode — is chosen by
70
+ the person deploying it.
71
+
72
+ ```python
73
+ from langchain.agents import create_agent
74
+ from ctrlrun import Control
75
+ from ctrlrun_langchain import CTRLRunMiddleware
76
+
77
+ control = Control.from_file("ctrlrun.yaml")
78
+
79
+ agent = create_agent(
80
+ model="gpt-5.5",
81
+ tools=[lookup, issue_refund],
82
+ middleware=[CTRLRunMiddleware(control)],
83
+ )
84
+ ```
85
+
86
+ With a policy that says refunds up to €50 are autonomous and the rest are denied:
87
+
88
+ ```yaml
89
+ schema: ctrlrun.policy/v2
90
+ actions:
91
+ lookup:
92
+ decision: allow
93
+ issue_refund:
94
+ effect: "refund:{payment_id}"
95
+ rules:
96
+ - when: { amount_gte: 0, amount_lte: 5000 }
97
+ decision: allow
98
+ - decision: deny
99
+ ```
100
+
101
+ the agent's own tool calls are decided before they run:
102
+
103
+ ```text
104
+ lookup the tool runs
105
+ issue_refund amount=900000 CTRLRun refused this call: rule[1]. The tool did not run.
106
+ rm_rf CTRLRun refused this call: unknown_action. The tool did not run.
107
+ issue_refund amount=1000 the tool runs
108
+ issue_refund amount=1000 (again) CTRLRun refused this call: this effect is already committed
109
+ ```
110
+
111
+ Nothing is default-allow: a tool the policy does not name is refused, which is why `rm_rf` above
112
+ never reaches `handler`.
113
+
114
+ **Every protected call needs a principal.** In production that is an identity provider that
115
+ verifies a credential; in development it is `with ctrlrun.context(agent="support-agent"):`
116
+ around the agent invocation. Without one the action is denied before the policy is consulted:
117
+
118
+ ```text
119
+ ActionDenied: lookup: no principal is available; wrap the call in
120
+ 'with ctrlrun.context(agent=...)', or install an identity provider that answers
121
+ ```
122
+
123
+ That is fail-closed and deliberate: who is acting is an authorization input, and a library that
124
+ accepted a self-asserted principal would be accepting the agent's word for its own authority.
125
+
126
+ ## Approvals
127
+
128
+ Where the policy says `approve`, this middleware refuses the call and tells the model the
129
+ request id, rather than blocking the agent while a human deliberates:
130
+
131
+ ```text
132
+ CTRLRun is holding this call for a human. Approve it with 'ctrlrun approve apr_...',
133
+ then ask again. The tool did not run.
134
+ ```
135
+
136
+ If you want the human answered *inside* the run instead, that is what `ctrlrun-langgraph` is
137
+ for: LangGraph's `interrupt()` suspends the graph, and the resumed run re-presents the same
138
+ proposal under the granted approval.
139
+
140
+ ## What this does not do
141
+
142
+ - It does not decide anything. The policy does, and the policy is the operator's file.
143
+ - It does not grant approvals. `InterruptApprovalProvider`, `ctrlrun approve` and the webhook
144
+ are the only places a grant is written, and this is not one of them.
145
+ - It does not supply a principal, and it never reads one from agent state.
146
+
147
+ Apache-2.0, same as the kernel.
@@ -0,0 +1,5 @@
1
+ ctrlrun_langchain/__init__.py,sha256=1p1GzHC7Yap-SvBactl01rX92Rv6y5KZd-o8g0hJAXo,8378
2
+ ctrlrun_langchain-1.0.0.dist-info/METADATA,sha256=R5rnBgZ69e1jlYPCvkLy4_zjXn1riKOBWQjZpKstq4I,6230
3
+ ctrlrun_langchain-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ ctrlrun_langchain-1.0.0.dist-info/top_level.txt,sha256=HMAdxCF8QUr80gxLYdsK5M8CqCAmdp8qxfE46NBo-H0,18
5
+ ctrlrun_langchain-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ ctrlrun_langchain