From 67ee38910ba7b1f518b2865cc69e02c55319acbc Mon Sep 17 00:00:00 2001 From: Arpan Ghoshal Date: Wed, 16 Sep 2026 04:58:04 +0530 Subject: [PATCH 1/3] adapters: ctrlrun-langchain, a wrap_tool_call middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LangChain 1.x middleware hands the tool call itself to the middleware: "Intercept execution and control when the handler is called. You decide if the handler is called zero times (short-circuit), once, or multiple times." So `handler` is the executor Control.execute has always wanted, and this is the first integration where the outcome needs no separate report to arrive. That is the difference from ctrlrun-langgraph, which is an adapter in SPEC-v0.5 §2's sense: it reuses interrupt() so a human answers inside the run. This reuses nothing about approvals and instead takes the call. Where the policy says approve, it refuses and hands back the request id rather than half-implementing an interrupt; the README points at the LangGraph adapter for answering in-run. Every test asserts on whether the handler ran, not on the message returned. A test that only checked the refusal text would pass against a middleware that refused and then ran the tool anyway. Covers the three properties an observation-hook integration cannot offer: a denial never reaches the tool, the same effect key does not run twice, and a handler that raises anything other than NotExecuted leaves the effect AMBIGUOUS so the retry is refused rather than becoming a double charge. PUBLISHED.toml is deliberately untouched: that file records what is on PyPI, and a row is added after an upload succeeds, not beside the change that motivates it. Signed-off-by: Arpan Ghoshal --- adapters/langchain/README.md | 128 +++++++++ adapters/langchain/pyproject.toml | 43 +++ .../src/ctrlrun_langchain/__init__.py | 196 ++++++++++++++ tests/test_adapters_langchain.py | 247 ++++++++++++++++++ 4 files changed, 614 insertions(+) create mode 100644 adapters/langchain/README.md create mode 100644 adapters/langchain/pyproject.toml create mode 100644 adapters/langchain/src/ctrlrun_langchain/__init__.py create mode 100644 tests/test_adapters_langchain.py diff --git a/adapters/langchain/README.md b/adapters/langchain/README.md new file mode 100644 index 0000000..6307325 --- /dev/null +++ b/adapters/langchain/README.md @@ -0,0 +1,128 @@ +# 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`](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. +- **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.** `handler` is not called, and the model gets a + `ToolMessage` saying the call was refused and which rule refused it. +- **Once stays once.** The effect is reserved before `handler` runs and committed from its + return, so two agents sharing a store cannot both execute the same effect key. +- **An unknown outcome stays unknown.** Anything `handler` raises that is not `NotExecuted` + leaves the effect `AMBIGUOUS`, 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 + +```console +$ 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. + +```python +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: + +```yaml +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: + +```text +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: + +```text +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: + +```text +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 approve` and 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. diff --git a/adapters/langchain/pyproject.toml b/adapters/langchain/pyproject.toml new file mode 100644 index 0000000..57daa57 --- /dev/null +++ b/adapters/langchain/pyproject.toml @@ -0,0 +1,43 @@ +# ctrlrun-langchain — a separate distribution on the adapters track (SPEC-v0.5 §6). +# +# `pip install ctrlrun` must not grow. This depends on `ctrlrun`, never the reverse, and the +# `ctrlrun` wheel and sdist contain no `adapters/` path (T136). +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "ctrlrun-langchain" +version = "1.0.0" +description = "Gate a LangChain agent's tool calls with a CTRLRun policy, through wrap_tool_call." +readme = "README.md" +requires-python = ">=3.11" +authors = [{name = "Arpan Ghoshal", email = "contact@arpanghoshal.com"}] +license = "Apache-2.0" +keywords = ["langchain", "ctrlrun", "middleware", "guardrails", "agent", "human-in-the-loop"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries", + "Topic :: Security", +] +# The two ranges SPEC-v0.5 §6.3 requires. `wrap_tool_call` is a LangChain 1.x surface, and the +# README states the same two, which T137's sibling asserts. +dependencies = [ + "ctrlrun>=0.12,<0.13", + "langchain>=1.0,<2.0", +] + +[project.urls] +Homepage = "https://github.com/CTRLRun/ctrlrun" +Repository = "https://github.com/CTRLRun/ctrlrun" + +[tool.setuptools.packages.find] +where = ["src"] + +# `langchain` ships no stubs mypy can resolve from outside its own tree. CI type-checks `src` +# only (scripts/check.sh), so this is for anyone running mypy over the adapter directly. +[[tool.mypy.overrides]] +module = ["langchain.*", "langchain_core.*"] +ignore_missing_imports = true diff --git a/adapters/langchain/src/ctrlrun_langchain/__init__.py b/adapters/langchain/src/ctrlrun_langchain/__init__.py new file mode 100644 index 0000000..b30eb13 --- /dev/null +++ b/adapters/langchain/src/ctrlrun_langchain/__init__.py @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""Gate a LangChain agent's tool calls with a CTRLRun policy, through `wrap_tool_call`. + +**This is not the LangGraph adapter, and it is not an adapter at all in SPEC-v0.5 §2's sense.** +`ctrlrun-langgraph` exists to route an `APPROVE` through `interrupt()`; it reuses a framework's +human-in-the-loop primitive and contributes two lines. This is the other thing entirely: a +`wrap_tool_call` middleware, where the framework hands over the call itself. + +The distinction matters because of what `wrap_tool_call` is. LangChain's own documentation: + + 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 (retry logic). + +So `handler` **is** the tool call. That makes it the executor `Control.execute` has always +wanted, and it closes the gap every observation-hook integration has to live 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 worth stating, because they are the reason to use this over a log-and-hope +callback: + +- **A denial never reaches the tool.** The handler is not called, and the model gets a + `ToolMessage` saying the call was refused and why. +- **`once stays once` is real here.** The effect is reserved before `handler` runs and committed + from its return, so two agents sharing a store cannot both execute the same effect key. +- **An unknown outcome stays unknown.** If `handler` raises something that is not `NotExecuted`, + the effect is `AMBIGUOUS` 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. This buys one thing: the gate applies to +*every* tool the agent can reach, including tools you did not write and cannot decorate. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any, Final + +from langchain.agents.middleware import AgentMiddleware + +from ctrlrun import ( + Action, + ActionDenied, + AmbiguousEffect, + ApprovalRequired, + Control, + DuplicateEffect, +) +from ctrlrun.effect import resolve_resource + +__all__ = ["CTRLRunMiddleware"] + +#: What the model is told when CTRLRun refuses. A refusal is the statement that the tool did +#: not run, which is not the same as the tool failing, so it names the rule rather than +#: reporting an error the tool never produced. +_REFUSED: Final = "CTRLRun refused this call: {reason}. The tool did not run." + + +def _tool_message(request: Any, content: str) -> Any: + """The refusal, in the shape LangChain's own limit middleware uses.""" + from langchain_core.messages import ToolMessage + + call = request.tool_call + return ToolMessage( + content=content, + tool_call_id=call["id"], + name=call.get("name"), + status="error", + ) + + +class CTRLRunMiddleware(AgentMiddleware): + """`AgentMiddleware` that runs every tool call through a `Control`. + + The **operator** constructs it, on the line where the policy, the store and the identity + provider are chosen. This class never constructs a `Control`: everything it must not + decide is decided by the person deploying it (SPEC-v0.5 §2.3). + + control = Control(policy, store, identity=..., authority=...) + agent = create_agent(model, tools=[...], middleware=[CTRLRunMiddleware(control)]) + + `resource` and `effect` are templates over the tool's arguments, exactly as `@protect`'s + are, and the policy's own entries are used where none is given here. + """ + + def __init__( + self, + control: Control, + *, + resource: str | None = None, + effect: str | None = None, + task: str | None = None, + ) -> None: + super().__init__() + self._control = control + self._resource = resource + self._effect = effect + self._task = task + + # -- the hook --------------------------------------------------------------------- + + def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any: + """Decide, then run the handler as the executor, then record what it did.""" + call = request.tool_call + name = call.get("name") or "" + arguments: Mapping[str, Any] = call.get("args") or {} + + try: + action = self._action(name, arguments) + except Exception as exc: # a policy that cannot name this tool is a refusal + return _tool_message(request, _REFUSED.format(reason=f"could not be evaluated: {exc}")) + + returned: list[Any] = [] + + def executor() -> Any: + # `handler` is the tool call. Its return value is the outcome, and anything it + # raises that is not `NotExecuted` leaves the effect AMBIGUOUS, which is the + # honest state for a call whose result nobody established. + result = handler(request) + returned.append(result) + return result + + try: + self._control.execute(action, executor, self._effect_key(name, arguments)) + except ActionDenied as denied: + return _tool_message(request, _REFUSED.format(reason=denied.reason)) + except ApprovalRequired as pending: + return _tool_message( + request, + f"CTRLRun is holding this call for a human. Approve it with " + f"'ctrlrun approve {pending.request_id}', then ask again. The tool did not run.", + ) + except DuplicateEffect as duplicate: + return _tool_message( + request, + f"CTRLRun refused this call: this effect is already {duplicate.state} " + f"({duplicate.effect_key}). The tool did not run.", + ) + except AmbiguousEffect as ambiguous: + return _tool_message( + request, + f"CTRLRun refused this call: the outcome of {ambiguous.effect_key} was never " + f"established, so a retry is unsafe. Resolve it with " + f"'ctrlrun resolve {ambiguous.effect_key}'. The tool did not run.", + ) + + return returned[0] if returned else None + + async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any: + """Async agents reach the same decision through the same `Control`. + + Deliberately not a parallel implementation. `Control.execute` is synchronous and owns + the reservation, so a second async path would be a second place the once-only rule is + enforced, and a second place to get it wrong. + """ + import anyio + + result: list[Any] = [] + + def run() -> None: + result.append(self.wrap_tool_call(request, lambda r: anyio.from_thread.run(handler, r))) + + await anyio.to_thread.run_sync(run) + return result[0] + + # -- internals -------------------------------------------------------------------- + + def _action(self, name: str, arguments: Mapping[str, Any]) -> Action: + """The action this tool call proposes. + + The principal comes from `Control.resolve_principal`, never from the agent's state: a + principal supplied by the caller is not an authorization input (SPEC-v0.3 §4.2). + """ + principal = self._control.resolve_principal(name) + template = ( + self._resource + if self._resource is not None + else (self._control.policy.resource_template(name)) + ) + return Action( + name=name, + arguments=dict(arguments), + principal=principal, + resource=None if template is None else resolve_resource(template, arguments), + environment=self._control.environment, + ) + + def _effect_key(self, name: str, arguments: Mapping[str, Any]) -> str | None: + template = ( + self._effect + if self._effect is not None + else (self._control.policy.effect_template(name)) + ) + return None if template is None else resolve_resource(template, arguments) diff --git a/tests/test_adapters_langchain.py b/tests/test_adapters_langchain.py new file mode 100644 index 0000000..19757ed --- /dev/null +++ b/tests/test_adapters_langchain.py @@ -0,0 +1,247 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""The LangChain middleware adapter. SPEC-v0.5 §6, §7. + +Every test here drives `wrap_tool_call` with a handler that **records whether it ran**, because +that is the whole claim: a refusal is the statement that the tool did not run, and a test that +only asserted the returned message would pass just as well against a middleware that refused +*and then ran it anyway*. + +Skipped **by name** where `langchain` is not installed, so a green run with the framework +missing cannot look like a pass (`v0.4 §7` T123's rule, applied here). +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path +from typing import Any + +import pytest + +import ctrlrun +from ctrlrun import Control +from ctrlrun.policy import Policy +from ctrlrun.state import InMemoryStateStore + +pytest.importorskip("langchain", reason="langchain is not installed") +pytest.importorskip("ctrlrun_langchain", reason="ctrlrun-langchain is not installed") + +from ctrlrun_langchain import CTRLRunMiddleware + +ADAPTER = Path(__file__).resolve().parents[1] / "adapters" / "langchain" + +POLICY = """ +schema: ctrlrun.policy/v2 +actions: + lookup: + decision: allow + refund: + effect: "refund:{payment_id}" + rules: + - when: { amount_gte: 0, amount_lte: 5000 } + decision: allow + - decision: deny + escalate: + decision: approve +""" + + +class Request: + """The two fields of `ToolCallRequest` this middleware reads.""" + + def __init__(self, name: str, args: dict[str, Any]) -> None: + self.tool_call = {"id": "call_1", "name": name, "args": args} + + +@pytest.fixture +def control(tmp_path) -> Control: + source = tmp_path / "ctrlrun.yaml" + source.write_text(POLICY, encoding="utf-8") + return Control(Policy.from_file(source), InMemoryStateStore()) + + +@pytest.fixture +def ran() -> list[str]: + return [] + + +@pytest.fixture +def handler(ran): + def _handler(request: Any) -> str: + ran.append(request.tool_call["name"]) + return "TOOL-RAN" + + return _handler + + +def _content(result: Any) -> str: + return getattr(result, "content", str(result)) + + +# --- the decision reaches the tool, or does not ---------------------------------------------- + + +def test_an_allowed_call_runs_and_returns_what_the_handler_returned(control, handler, ran): + with ctrlrun.context(agent="langchain-agent"): + assert ( + CTRLRunMiddleware(control).wrap_tool_call(Request("lookup", {}), handler) == "TOOL-RAN" + ) + assert ran == ["lookup"] + + +def test_a_denied_call_never_reaches_the_handler(control, handler, ran): + """The claim is non-execution, so the assertion is on `ran`, not on the message.""" + with ctrlrun.context(agent="langchain-agent"): + result = CTRLRunMiddleware(control).wrap_tool_call( + Request("refund", {"payment_id": "p1", "amount": 900_000}), handler + ) + assert ran == [] + assert "did not run" in _content(result) + + +def test_an_unnamed_tool_is_refused_because_nothing_is_default_allow(control, handler, ran): + with ctrlrun.context(agent="langchain-agent"): + result = CTRLRunMiddleware(control).wrap_tool_call(Request("rm_rf", {}), handler) + assert ran == [] + assert "unknown_action" in _content(result) + + +def test_the_refusal_names_the_rule_rather_than_reporting_an_error_the_tool_never_produced( + control, handler +): + with ctrlrun.context(agent="langchain-agent"): + result = CTRLRunMiddleware(control).wrap_tool_call( + Request("refund", {"payment_id": "p1", "amount": 900_000}), handler + ) + assert "rule[" in _content(result) + assert getattr(result, "status", None) == "error" + + +# --- once stays once ------------------------------------------------------------------------- + + +def test_the_same_effect_key_does_not_run_twice(control, handler, ran): + middleware = CTRLRunMiddleware(control) + call = Request("refund", {"payment_id": "p2", "amount": 1000}) + with ctrlrun.context(agent="langchain-agent"): + assert middleware.wrap_tool_call(call, handler) == "TOOL-RAN" + second = middleware.wrap_tool_call( + Request("refund", {"payment_id": "p2", "amount": 1000}), handler + ) + assert ran == ["refund"], "the second attempt reached the tool" + assert "already committed" in _content(second) + + +# --- an approval is a refusal here, and says how to answer it -------------------------------- + + +def test_an_approve_decision_refuses_and_names_the_request(control, handler, ran): + """This middleware has no interrupt to route through, so it refuses and hands back the id. + + `ctrlrun-langgraph` is the adapter for answering inside the run; the README says so, and + this test is what keeps the two distinguishable rather than half-implementing an interrupt. + """ + with ctrlrun.context(agent="langchain-agent"): + result = CTRLRunMiddleware(control).wrap_tool_call(Request("escalate", {}), handler) + assert ran == [] + assert "ctrlrun approve" in _content(result) + + +# --- what a failing tool does, which is not what a refused one does -------------------------- + + +def test_a_tool_that_raises_leaves_the_outcome_unknown_rather_than_failed(control, ran): + """Anything that is not `NotExecuted` is AMBIGUOUS (v0.1 §5.5), so the retry is refused. + + This is the property the observation-hook integrations cannot offer, and the reason the + handler is run as the executor rather than reported on afterwards. + """ + middleware = CTRLRunMiddleware(control) + + def explodes(request: Any) -> str: + ran.append("tried") + raise RuntimeError("the provider timed out") + + with ctrlrun.context(agent="langchain-agent"): + with pytest.raises(RuntimeError): + middleware.wrap_tool_call( + Request("refund", {"payment_id": "p3", "amount": 1000}), explodes + ) + retry = middleware.wrap_tool_call( + Request("refund", {"payment_id": "p3", "amount": 1000}), explodes + ) + + assert ran == ["tried"], "the retry reached the tool while the outcome was unknown" + assert "never established" in _content(retry) + assert "ctrlrun resolve" in _content(retry) + + +# --- the principal is read, never supplied --------------------------------------------------- + + +def test_without_a_principal_the_call_is_refused_before_the_policy_is_consulted( + control, handler, ran +): + """SPEC-v0.3 §4.2. A principal taken from agent state is the one input authority may not + accept, so the absence of one is a refusal rather than a default.""" + result = CTRLRunMiddleware(control).wrap_tool_call(Request("lookup", {}), handler) + assert ran == [] + assert "no principal is available" in _content(result) + + +# --- §7's README requirements, and §6.3's two ranges ----------------------------------------- + + +def _needs_the_source_tree() -> None: + if not ADAPTER.is_dir(): # pragma: no cover - running from an unpacked sdist + pytest.skip("adapters/ is not in this distribution, which SPEC-v0.5 §6.1 requires") + + +def readme() -> str: + _needs_the_source_tree() + return (ADAPTER / "README.md").read_text(encoding="utf-8") + + +def declared() -> dict[str, str]: + _needs_the_source_tree() + with (ADAPTER / "pyproject.toml").open("rb") as handle: + project = tomllib.load(handle)["project"] + return {name.split(">")[0].split("<")[0].strip(): name for name in project["dependencies"]} + + +def test_the_readme_and_the_metadata_state_the_same_two_ranges(): + """SPEC-v0.5 §6.3. A README that says one thing and metadata another is the version + somebody typed.""" + text = readme() + for specifier in declared().values(): + assert specifier in text, f"{specifier!r} is in pyproject.toml and not in the README" + assert "Supported kernel range" in text + assert "Supported framework range" in text + + +def test_the_declared_framework_range_contains_the_version_ci_installed(): + from importlib.metadata import version as installed + + from packaging.specifiers import SpecifierSet + + specifier = declared()["langchain"].removeprefix("langchain") + assert installed("langchain") in SpecifierSet(specifier), ( + f"CI ran against langchain {installed('langchain')}, which the declared range " + f"{specifier!r} excludes" + ) + + +def test_the_readme_names_the_primitive_it_reuses_and_where_it_is_documented(): + """§7 item 2: the name, a link and a date, so a reader can check what was true when it + was written.""" + text = readme() + assert "wrap_tool_call" in text + assert "docs.langchain.com" in text + assert "Read 2026-09-16" in text + + +def test_the_readme_says_you_may_not_need_it(): + """§7. `@protect` covers any callable; an adapter that did not say so would be selling + itself over the simpler thing that already works.""" + assert "You may not need this" in readme() From a031f6941475bc2a2c56954d936875ad59d3ea95 Mon Sep 17 00:00:00 2001 From: Arpan Ghoshal Date: Wed, 16 Sep 2026 04:59:38 +0530 Subject: [PATCH 2/3] Draft the LangChain middleware integration page, held until the package ships langchain-ai/docs invites this directly: 'Middleware enables context engineering, harness customization, and runtime safety controls ... we love highlighting what the community builds with it', with a TEMPLATE.mdx to follow. Only five middleware pages exist today and all five are first-party vendors. Held rather than submitted because the details table renders live PyPI version and download badges. A page whose badges 404 is a page that gets closed, so it goes in after ctrlrun-langchain is on PyPI, together with the docs.json nav entry and the all-integrations table row the index asks for. Signed-off-by: Arpan Ghoshal --- adapters/langchain/DOCS-PAGE-DRAFT.mdx | 120 +++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 adapters/langchain/DOCS-PAGE-DRAFT.mdx diff --git a/adapters/langchain/DOCS-PAGE-DRAFT.mdx b/adapters/langchain/DOCS-PAGE-DRAFT.mdx new file mode 100644 index 0000000..41ce2e9 --- /dev/null +++ b/adapters/langchain/DOCS-PAGE-DRAFT.mdx @@ -0,0 +1,120 @@ +--- +title: "CTRLRun integration" +description: "Integrate with the CTRLRun middleware using LangChain Python." +--- + + + +This guide provides a quick overview for getting started with the CTRLRun [middleware](/oss/langchain/middleware/overview/). CTRLRun checks every tool call your agent makes against a policy you write, before the call runs, and records what happened after. + +## Overview + +### Details + +| Class | Package | Serializable | Downloads | Version | +| :--- | :--- | :---: | :---: | :---: | +| `CTRLRunMiddleware` | [`ctrlrun-langchain`](https://pypi.org/project/ctrlrun-langchain/) | beta/❌ | ![PyPI - Downloads](https://img.shields.io/pypi/dm/ctrlrun-langchain?style=flat-square&label=%20) | ![PyPI - Version](https://img.shields.io/pypi/v/ctrlrun-langchain?style=flat-square&label=%20) | + +### Features + +- **Policy-gated tool calls** — a refused call never reaches the tool, and the model is told which rule refused it +- **Once stays once** — an effect key executes at most once, across processes sharing a store +- **Unknown outcomes stay unknown** — a tool that raises leaves the effect unresolved rather than retried +- **Human approval** — a policy decision of `approve` holds the call for a person +- **A receipt for every decision** — requests, decisions and results, refusals included + +--- + +## Setup + +No account and no API key. CTRLRun is a library, and the policy is a file in your repository. + +### Installation + +```bash +pip install ctrlrun-langchain +``` + +### Write a policy + +`ctrlrun.yaml` says how much autonomy each tool gets. Unknown tools are denied; there is no default-allow. + +```yaml +schema: ctrlrun.policy/v2 +actions: + lookup_order: + decision: allow + issue_refund: + effect: "refund:{payment_id}" + rules: + - when: { amount_gte: 0, amount_lte: 5000 } # up to €50.00, autonomous + decision: allow + - when: { amount_gte: 0, amount_lte: 500000 } # up to €5,000.00, ask a human + decision: approve + - decision: deny +``` + +## Instantiation + +```python +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_order, issue_refund], + middleware=[CTRLRunMiddleware(control)], +) +``` + +## Invocation + +```python +import ctrlrun + +with ctrlrun.context(agent="support-agent"): + result = agent.invoke({"messages": [{"role": "user", "content": "refund order 4471"}]}) +``` + +Every protected call needs a principal: who is acting is an authorization input, so a call without one is denied before the policy is consulted. `ctrlrun.context(...)` supplies it in development; in production an identity provider verifies a credential instead. + +## What the agent sees + +The middleware uses [`wrap_tool_call`](/oss/langchain/middleware/custom), so a refused call is short-circuited — the tool is never invoked, and the model receives a `ToolMessage` explaining why: + +```text +issue_refund amount=900000 CTRLRun refused this call: rule[2]. 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 CTRLRun refused this call: this effect is already committed +``` + +That last line is the property worth knowing about. Because `handler` is the executor, the effect is reserved before the tool runs and committed from what it returned. Two agents sharing a store cannot both execute the same effect key, and a tool that raises leaves the outcome `AMBIGUOUS` rather than `FAILED` — so the retry is refused until a person resolves it, instead of becoming a double charge. + +## Approvals + +Where the policy says `approve`, the call is held and the model is told how to release it: + +```text +CTRLRun is holding this call for a human. Approve it with 'ctrlrun approve apr_...', +then ask again. The tool did not run. +``` + +To have the human answered *inside* the run instead, use [`ctrlrun-langgraph`](https://pypi.org/project/ctrlrun-langgraph/), which routes the approval through LangGraph's `interrupt()` and re-presents the same proposal on resume. + +## API reference + +- [CTRLRun documentation](https://docs.ctrlrun.dev/) +- [`ctrlrun-langchain` source](https://github.com/CTRLRun/ctrlrun/tree/main/adapters/langchain) From f4fcc7f604faea9c5628c447c3d1af82e73599b9 Mon Sep 17 00:00:00 2001 From: Arpan Ghoshal Date: Wed, 16 Sep 2026 05:05:01 +0530 Subject: [PATCH 3/3] Pin the whole docs URL in the README assertion, not the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flags 'docs.langchain.com' in text as py/incomplete-url-substring-sanitization, high severity: a bare hostname substring is the shape of a URL check that https://evil.example/docs.langchain.com would satisfy. It is a test assertion rather than a sanitizer, so nothing was exploitable, but the rule is right that the assertion was the weak one. SPEC-v0.5 §7 asks the README to name where the primitive is documented, so the page is what the test should pin. It now does. Signed-off-by: Arpan Ghoshal --- tests/test_adapters_langchain.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_adapters_langchain.py b/tests/test_adapters_langchain.py index 19757ed..16212a3 100644 --- a/tests/test_adapters_langchain.py +++ b/tests/test_adapters_langchain.py @@ -237,7 +237,10 @@ def test_the_readme_names_the_primitive_it_reuses_and_where_it_is_documented(): was written.""" text = readme() assert "wrap_tool_call" in text - assert "docs.langchain.com" in text + # The whole URL, not the host. A bare hostname reads as a URL-sanitization check to + # CodeQL (py/incomplete-url-substring-sanitization) and is the weaker assertion anyway: + # what §7 asks for is the page, so that is what this pins. + assert "https://docs.langchain.com/oss/langchain/middleware/custom" in text assert "Read 2026-09-16" in text