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) 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..16212a3 --- /dev/null +++ b/tests/test_adapters_langchain.py @@ -0,0 +1,250 @@ +# 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 + # 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 + + +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()