ory-langchain 0.13.9__tar.gz

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,36 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ .env
5
+ .env.local
6
+
7
+ # Python (uv workspace under python/)
8
+ .venv/
9
+ __pycache__/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ build/
14
+ *.pyc
15
+
16
+ # Debug logs
17
+ *.log
18
+
19
+ # Harness sandbox dirs
20
+ .sandbox/
21
+
22
+ # Staged install-surface repo contents (see scripts/sync-install-surfaces.mjs)
23
+ .install-surfaces/
24
+
25
+ # Local dev environment (local Ory stack + Verdaccio registry)
26
+ .ory-dev/
27
+
28
+ # Worktrees for changes
29
+ .worktrees/
30
+ .claude/worktrees/
31
+
32
+ # Gemini CLI extension assets — materialized at install time from
33
+ # @ory/argus templates. The repo source is the canonical templates;
34
+ # these subdirs are generated wherever the install runs.
35
+ packages/gemini-cli/gemini-extension/skills/
36
+ packages/gemini-cli/gemini-extension/commands/
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: ory-langchain
3
+ Version: 0.13.9
4
+ Summary: Ory Agent Security for LangChain / LangGraph — per-tool authorization, tracing, and identity propagation via an AgentMiddleware. Built on ory-argus.
5
+ Author: Ory
6
+ License-Expression: Apache-2.0
7
+ Keywords: agent,ai,authorization,langchain,langgraph,ory,permissions
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: langchain<2,>=1
10
+ Requires-Dist: ory-argus<1,>=0.8
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == 'dev'
13
+ Requires-Dist: ruff>=0.6; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ory-langchain
17
+
18
+ Ory Agent Security for [LangChain](https://docs.langchain.com) / LangGraph.
19
+
20
+ Adds three things to a LangChain agent with one line:
21
+
22
+ 1. **Authorization** — every tool call is checked against Ory Permissions before it runs.
23
+ 2. **Tracing** — every invocation is recorded as a structured span (OTLP + NDJSON).
24
+ 3. **Identity propagation** — user → agent → sub-agent delegation, recorded in Ory.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install ory-langchain
30
+ ```
31
+
32
+ ## Use
33
+
34
+ ```python
35
+ from langchain.agents import create_agent
36
+ from ory_langchain import OryMiddleware
37
+
38
+ agent = create_agent(
39
+ model,
40
+ tools=[search, send_email],
41
+ middleware=[OryMiddleware()], # gate every tool call through Ory
42
+ )
43
+ ```
44
+
45
+ `OryMiddleware()` reads the shared Ory config at `~/.config/ory-agent-plugins/config.json`,
46
+ so a login performed by any Ory coding-agent harness — or by `ory-argus login` — is reused
47
+ transparently. Configure a project explicitly with `OryMiddleware(project_url=...)`.
48
+
49
+ ### Permission mode
50
+
51
+ - `observe` (default) — denied tools still run, but a `permission.observe_deny` span is
52
+ recorded. Use this to see what *would* be blocked before enforcing.
53
+ - `enforce` — denied tools are blocked: the model receives an error `ToolMessage` and the
54
+ tool never executes. Set `ORY_PERMISSION_MODE=enforce` or run `ory-argus permissions enforce`.
55
+
56
+ ### Tracing only
57
+
58
+ If you only want observability (no enforcement), use the callback handler instead:
59
+
60
+ ```python
61
+ from ory_langchain import OryCallbackHandler
62
+
63
+ agent.invoke({"messages": [...]}, config={"callbacks": [OryCallbackHandler()]})
64
+ ```
65
+
66
+ Built on [`ory-argus`](https://pypi.org/project/ory-argus/), the shared Ory Agent Security core.
@@ -0,0 +1,51 @@
1
+ # ory-langchain
2
+
3
+ Ory Agent Security for [LangChain](https://docs.langchain.com) / LangGraph.
4
+
5
+ Adds three things to a LangChain agent with one line:
6
+
7
+ 1. **Authorization** — every tool call is checked against Ory Permissions before it runs.
8
+ 2. **Tracing** — every invocation is recorded as a structured span (OTLP + NDJSON).
9
+ 3. **Identity propagation** — user → agent → sub-agent delegation, recorded in Ory.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install ory-langchain
15
+ ```
16
+
17
+ ## Use
18
+
19
+ ```python
20
+ from langchain.agents import create_agent
21
+ from ory_langchain import OryMiddleware
22
+
23
+ agent = create_agent(
24
+ model,
25
+ tools=[search, send_email],
26
+ middleware=[OryMiddleware()], # gate every tool call through Ory
27
+ )
28
+ ```
29
+
30
+ `OryMiddleware()` reads the shared Ory config at `~/.config/ory-agent-plugins/config.json`,
31
+ so a login performed by any Ory coding-agent harness — or by `ory-argus login` — is reused
32
+ transparently. Configure a project explicitly with `OryMiddleware(project_url=...)`.
33
+
34
+ ### Permission mode
35
+
36
+ - `observe` (default) — denied tools still run, but a `permission.observe_deny` span is
37
+ recorded. Use this to see what *would* be blocked before enforcing.
38
+ - `enforce` — denied tools are blocked: the model receives an error `ToolMessage` and the
39
+ tool never executes. Set `ORY_PERMISSION_MODE=enforce` or run `ory-argus permissions enforce`.
40
+
41
+ ### Tracing only
42
+
43
+ If you only want observability (no enforcement), use the callback handler instead:
44
+
45
+ ```python
46
+ from ory_langchain import OryCallbackHandler
47
+
48
+ agent.invoke({"messages": [...]}, config={"callbacks": [OryCallbackHandler()]})
49
+ ```
50
+
51
+ Built on [`ory-argus`](https://pypi.org/project/ory-argus/), the shared Ory Agent Security core.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ory-langchain"
7
+ version = "0.13.9"
8
+ description = "Ory Agent Security for LangChain / LangGraph — per-tool authorization, tracing, and identity propagation via an AgentMiddleware. Built on ory-argus."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Ory" }]
13
+ keywords = ["ory", "langchain", "langgraph", "authorization", "agent", "ai", "permissions"]
14
+ dependencies = [
15
+ "ory-argus>=0.8,<1",
16
+ "langchain>=1,<2",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8", "ruff>=0.6"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/ory_langchain"]
@@ -0,0 +1,23 @@
1
+ """Ory Agent Security for LangChain / LangGraph.
2
+
3
+ Drop ``OryMiddleware`` into a LangChain agent to gate every tool call through Ory
4
+ Permissions, trace every invocation, and propagate user → agent → sub-agent identity::
5
+
6
+ from langchain.agents import create_agent
7
+ from ory_langchain import OryMiddleware
8
+
9
+ agent = create_agent(model, tools=tools, middleware=[OryMiddleware()])
10
+
11
+ By default ``OryMiddleware()`` reads the shared Ory config
12
+ (``~/.config/ory-agent-plugins/config.json``), so a login performed by any Ory harness or
13
+ the ``ory-argus`` CLI is reused automatically.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .callbacks import OryCallbackHandler
19
+ from .middleware import OryMiddleware
20
+
21
+ __version__ = "0.13.9"
22
+
23
+ __all__ = ["OryMiddleware", "OryCallbackHandler", "__version__"]
@@ -0,0 +1,47 @@
1
+ """Trace-only LangChain callback handler.
2
+
3
+ ``OryCallbackHandler`` is a ``BaseCallbackHandler`` that records ``tool.invoke`` /
4
+ ``tool.complete`` spans for observability. It does **not** authorize — callbacks can't veto
5
+ a tool call in LangChain. Use it when you only want tracing; use :class:`OryMiddleware` when
6
+ you want enforcement (which also traces). Don't install both for the same agent, or tool
7
+ calls will be traced twice.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from ory_argus import OryAgentClient
15
+
16
+ _HARNESS = "langchain"
17
+
18
+
19
+ def _import_base():
20
+ from langchain_core.callbacks import BaseCallbackHandler
21
+
22
+ return BaseCallbackHandler
23
+
24
+
25
+ class OryCallbackHandler(_import_base()): # type: ignore[misc]
26
+ """Records trace spans for tool starts/ends (observation only)."""
27
+
28
+ def __init__(self, client: OryAgentClient | None = None) -> None:
29
+ super().__init__()
30
+ self._client = client or OryAgentClient.from_env(_HARNESS)
31
+
32
+ def on_tool_start(self, serialized: dict, input_str: str, **kwargs: Any) -> None:
33
+ name = (serialized or {}).get("name", "unknown")
34
+ self._client.tracer.record(
35
+ "tool.invoke", "ok", attributes={"toolName": name, "source": "callback"}
36
+ )
37
+
38
+ def on_tool_end(self, output: Any, **kwargs: Any) -> None:
39
+ self._client.tracer.record("tool.complete", "ok", attributes={"source": "callback"})
40
+
41
+ def on_tool_error(self, error: BaseException, **kwargs: Any) -> None:
42
+ self._client.tracer.record(
43
+ "tool.fail", "error", attributes={"source": "callback", "error": str(error)}
44
+ )
45
+
46
+
47
+ __all__ = ["OryCallbackHandler"]
@@ -0,0 +1,137 @@
1
+ """Ory Agent Security middleware for LangChain / LangGraph.
2
+
3
+ ``OryMiddleware`` is a LangChain v1 ``AgentMiddleware`` that intercepts **every** tool call
4
+ via ``wrap_tool_call`` — the canonical before+after extension point. On the first call it
5
+ runs the Ory session gates (user + agent auth, user→agent delegation); on each call it
6
+ authorizes the tool against Ory Permissions and records trace spans:
7
+
8
+ - **allow / observe / fail-open / interactive** → call the wrapped ``handler`` (tool runs)
9
+ and record ``tool.complete``.
10
+ - **deny (enforce mode)** → return a ``ToolMessage`` with ``status="error"`` *without*
11
+ invoking the handler, so the tool never executes and the model sees the denial.
12
+
13
+ All heavy lifting lives in ``ory_argus`` (the shared core); this module is the thin
14
+ LangChain translation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from ory_argus import (
22
+ OryAgentClient,
23
+ complete,
24
+ gate,
25
+ register_subagent,
26
+ session_start,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from langchain_core.messages import ToolMessage
31
+
32
+ _HARNESS = "langchain"
33
+
34
+
35
+ def _import_middleware_base():
36
+ try:
37
+ from langchain.agents.middleware import AgentMiddleware
38
+
39
+ return AgentMiddleware
40
+ except Exception as err: # pragma: no cover - import guard
41
+ raise ImportError(
42
+ "ory-langchain requires langchain>=1. Install it with `pip install ory-langchain` "
43
+ "(which pulls langchain) or `pip install 'langchain>=1,<2'`."
44
+ ) from err
45
+
46
+
47
+ def _block_message(tool_call: dict, reason: str) -> ToolMessage:
48
+ from langchain_core.messages import ToolMessage
49
+
50
+ return ToolMessage(
51
+ content=reason,
52
+ tool_call_id=str(tool_call.get("id") or ""),
53
+ name=tool_call.get("name"),
54
+ status="error",
55
+ )
56
+
57
+
58
+ class OryMiddleware(_import_middleware_base()): # type: ignore[misc]
59
+ """LangChain middleware that gates every tool call through Ory.
60
+
61
+ Args:
62
+ client: An ``OryAgentClient``. Defaults to ``OryAgentClient.from_env("langchain")``
63
+ (reads the shared config — reuses a TS-harness login transparently).
64
+ project_url: Override the Ory project URL for the session gates.
65
+ can_block: Whether a denied tool is hard-blocked. ``True`` (default) returns an
66
+ error ``ToolMessage`` in enforce mode; the alert span records ``blocked``.
67
+ subagent_tools: Tool names that spawn a sub-agent (e.g. LangGraph handoffs). When a
68
+ gated tool is in this set, a sub-agent identity + agent→subagent delegation
69
+ tuple is recorded (best-effort, audit-only).
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ *,
75
+ client: OryAgentClient | None = None,
76
+ project_url: str | None = None,
77
+ can_block: bool = True,
78
+ subagent_tools: set[str] | None = None,
79
+ ) -> None:
80
+ super().__init__()
81
+ self._client = client or OryAgentClient.from_env(_HARNESS)
82
+ self._project_url = project_url
83
+ self._can_block = can_block
84
+ self._subagent_tools = subagent_tools or set()
85
+ self._session_started = False
86
+
87
+ # ─── session ─────────────────────────────────────────────────────
88
+ def _ensure_session(self) -> None:
89
+ if self._session_started:
90
+ return
91
+ self._session_started = True
92
+ try:
93
+ session_start(self._client, harness=_HARNESS, project_url=self._project_url)
94
+ except Exception as err: # noqa: BLE001 — session gate must never break the agent
95
+ self._client.logger.warn("session_start.failed", {"message": str(err)})
96
+
97
+ # ─── core gate (shared by sync + async paths) ────────────────────
98
+ def _gate_then(self, request: Any):
99
+ """Returns ``(block_message_or_None, tool_name, tool_call)``."""
100
+ self._ensure_session()
101
+ tool_call = getattr(request, "tool_call", None) or {}
102
+ tool_name = tool_call.get("name") or getattr(getattr(request, "tool", None), "name", "unknown")
103
+ args = tool_call.get("args")
104
+
105
+ if tool_name in self._subagent_tools:
106
+ try:
107
+ register_subagent(
108
+ self._client, harness=_HARNESS, sub_agent_type=tool_name, project_url=self._project_url
109
+ )
110
+ except Exception as err: # noqa: BLE001
111
+ self._client.logger.warn("subagent.register.failed", {"tool": tool_name, "message": str(err)})
112
+
113
+ result = gate(
114
+ self._client, harness=_HARNESS, tool_name=tool_name, tool_args=args, can_block=self._can_block
115
+ )
116
+ if result.blocked:
117
+ return _block_message(tool_call, result.denial_message or "Ory: permission denied"), tool_name, tool_call
118
+ return None, tool_name, tool_call
119
+
120
+ def wrap_tool_call(self, request, handler): # type: ignore[override]
121
+ block, tool_name, _tc = self._gate_then(request)
122
+ if block is not None:
123
+ return block
124
+ response = handler(request)
125
+ complete(self._client, tool_name=tool_name)
126
+ return response
127
+
128
+ async def awrap_tool_call(self, request, handler): # type: ignore[override]
129
+ block, tool_name, _tc = self._gate_then(request)
130
+ if block is not None:
131
+ return block
132
+ response = await handler(request)
133
+ complete(self._client, tool_name=tool_name)
134
+ return response
135
+
136
+
137
+ __all__ = ["OryMiddleware"]
@@ -0,0 +1,77 @@
1
+ """Live end-to-end test for OryMiddleware against a real local Ory stack.
2
+
3
+ Opt-in: skipped unless ``ORY_E2E=1`` and ``ORY_USER_SUBJECT_ID`` are set, so the normal
4
+ ``pytest`` run (and CI) never needs Docker.
5
+
6
+ **Scope — deliberately minimal, no overlap with `test_middleware.py`.**
7
+ `test_middleware.py` stubs the permission check (via the `ory_argus` mock kit) to exercise
8
+ the adapter's full decision matrix hermetically: allow / observe / enforce-blocks /
9
+ fail-open / interactive pass-through / session-once. Those branches are pure translation
10
+ logic and are *not* re-checked here.
11
+
12
+ This test asserts only what a stub cannot: that the **real** check wiring — namespace,
13
+ relation (`use`), and subject form (SubjectSet `User:<id>`) — actually matches the tuples in
14
+ a live Keto, so a granted catalog tool is genuinely allowed and an un-tupled tool is
15
+ genuinely denied. One enforce-mode scenario covers both a real allow and a real deny.
16
+
17
+ Run::
18
+
19
+ pnpm local:up # seeds a user + `use` tuples; prints the env below
20
+ ORY_E2E=1 ORY_PROJECT_URL=http://localhost:4000 ORY_USER_SUBJECT_NAMESPACE=User \\
21
+ ORY_USER_SUBJECT_ID=<seeded-id> \\
22
+ uv run --extra dev pytest ory-langchain/tests/test_e2e.py
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+
29
+ import pytest
30
+
31
+ _LIVE = os.environ.get("ORY_E2E") == "1" and bool(os.environ.get("ORY_USER_SUBJECT_ID"))
32
+ pytestmark = pytest.mark.skipif(
33
+ not _LIVE, reason="live Ory stack required; set ORY_E2E=1 + the seeded ORY_USER_* env"
34
+ )
35
+
36
+ # Imported lazily-safe: langchain is a dependency of this package and is installed in the
37
+ # workspace venv, so these resolve during collection even when the test is skipped.
38
+ from langchain_core.messages import ToolMessage # noqa: E402
39
+ from langgraph.prebuilt.tool_node import ToolCallRequest # noqa: E402
40
+
41
+ from ory_argus import OryAgentClient # noqa: E402
42
+ from ory_langchain import OryMiddleware # noqa: E402
43
+
44
+ # A tool the seed grants `use` on (in the AgentTools catalog); override per environment.
45
+ GRANTED = os.environ.get("ORY_E2E_GRANTED_TOOL", "Read")
46
+ UNGRANTED = "ory_e2e_unauthorized_tool"
47
+
48
+
49
+ def _request(name: str) -> ToolCallRequest:
50
+ return ToolCallRequest(
51
+ tool_call={"name": name, "args": {}, "id": f"call-{name}", "type": "tool_call"},
52
+ tool=None, state={}, runtime=None,
53
+ )
54
+
55
+
56
+ def _handler(_request):
57
+ return ToolMessage(content="RAN", tool_call_id="x")
58
+
59
+
60
+ def test_enforce_allows_granted_blocks_ungranted(monkeypatch):
61
+ monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
62
+ client = OryAgentClient.from_env("langchain")
63
+ mw = OryMiddleware(client=client)
64
+
65
+ granted = mw.wrap_tool_call(_request(GRANTED), _handler)
66
+ ungranted = mw.wrap_tool_call(_request(UNGRANTED), _handler)
67
+
68
+ # Granted catalog tool is allowed by a real Keto tuple → the handler ran.
69
+ assert getattr(granted, "content", None) == "RAN"
70
+ # Un-tupled tool is denied by real Keto → blocked; the handler never ran.
71
+ assert getattr(ungranted, "status", None) == "error"
72
+ assert "permission denied" in str(ungranted.content).lower()
73
+
74
+ # Both outcomes came from real permission.check calls — one allowed, one denied.
75
+ check_statuses = {s.status for s in client.tracer.spans() if s.event == "permission.check"}
76
+ assert "ok" in check_statuses, "expected a real ALLOW from live Keto"
77
+ assert "denied" in check_statuses, "expected a real DENY from live Keto"
@@ -0,0 +1,175 @@
1
+ """Hermetic tests for the LangChain OryMiddleware.
2
+
3
+ Covers the SDK-native surfaces only: the enforce-deny veto shape (a ``ToolMessage`` with
4
+ ``status="error"``), request-shape extraction from a real ``ToolCallRequest``, and the
5
+ package-specific paths (session-once, callback handler, interactive pass-through). The full
6
+ permission decision matrix is core-owned (``ory-argus/tests/test_adapters.py``);
7
+ ``test_gate_matrix`` re-drives it through this middleware via ``run_gate_matrix`` as the
8
+ reference for the opt-in pattern.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ pytest.importorskip("langchain")
16
+
17
+ from langchain_core.messages import ToolMessage # noqa: E402
18
+ from langgraph.prebuilt.tool_node import ToolCallRequest # noqa: E402
19
+
20
+ from ory_argus.agent_auth import AgentCredentials # noqa: E402
21
+ from ory_argus.client import PrincipalIdentity # noqa: E402
22
+ from ory_argus.testing import ( # noqa: E402
23
+ GateCase,
24
+ create_mock_client,
25
+ get_trace_spans,
26
+ run_gate_matrix,
27
+ stub_permission_allowed,
28
+ stub_permission_denied,
29
+ stub_relationship_ok,
30
+ )
31
+ from ory_argus.user_login import UserLoginDecision # noqa: E402
32
+ from ory_langchain import OryCallbackHandler, OryMiddleware # noqa: E402
33
+
34
+
35
+ @pytest.fixture(autouse=True)
36
+ def _isolate(monkeypatch, tmp_path):
37
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
38
+ for var in ("ORY_PERMISSION_MODE", "ORY_PERMISSION_NAMESPACE", "ORY_INTERACTIVE_TOOLS"):
39
+ monkeypatch.delenv(var, raising=False)
40
+
41
+
42
+ def make_request(name="search", args=None, call_id="call-1"):
43
+ return ToolCallRequest(
44
+ tool_call={"name": name, "args": args or {}, "id": call_id, "type": "tool_call"},
45
+ tool=None,
46
+ state={},
47
+ runtime=None,
48
+ )
49
+
50
+
51
+ def make_middleware(client, **kw):
52
+ mw = OryMiddleware(client=client, **kw)
53
+ mw._session_started = True # skip session gate network in unit tests
54
+ return mw
55
+
56
+
57
+ def _handler_returns(msg):
58
+ def handler(request):
59
+ return msg
60
+
61
+ return handler
62
+
63
+
64
+ def test_allow_extracts_tool_name_and_runs_handler():
65
+ client = create_mock_client()
66
+ client.set_user_principal(PrincipalIdentity(subject="user:alice"))
67
+ stub_permission_allowed(client)
68
+ mw = make_middleware(client)
69
+
70
+ sentinel = ToolMessage(content="ok", tool_call_id="call-1")
71
+ out = mw.wrap_tool_call(make_request("search"), _handler_returns(sentinel))
72
+ assert out is sentinel
73
+ invoke = get_trace_spans(client, "tool.invoke")
74
+ assert invoke and invoke[0].attributes["allowed"] is True
75
+ # Tool name pulled from the ToolCallRequest's tool_call dict.
76
+ assert invoke[0].attributes["toolName"] == "search"
77
+ assert get_trace_spans(client, "tool.complete")
78
+
79
+
80
+ def test_enforce_blocks_without_running_handler(monkeypatch):
81
+ monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
82
+ client = create_mock_client()
83
+ client.set_user_principal(PrincipalIdentity(subject="user:alice"))
84
+ stub_permission_denied(client)
85
+ mw = make_middleware(client)
86
+ ran = {}
87
+
88
+ def handler(request):
89
+ ran["yes"] = True
90
+ return ToolMessage(content="should not run", tool_call_id="call-9")
91
+
92
+ out = mw.wrap_tool_call(make_request("rm", call_id="call-9"), handler)
93
+ assert "yes" not in ran # handler never invoked
94
+ # Native veto shape: an error ToolMessage carrying the original tool_call_id.
95
+ assert isinstance(out, ToolMessage)
96
+ assert out.status == "error"
97
+ assert out.tool_call_id == "call-9"
98
+ assert "permission denied" in out.content
99
+ block = get_trace_spans(client, "tool.block")[0]
100
+ assert block.attributes["blocked"] is True
101
+
102
+
103
+ def test_gate_matrix():
104
+ """Full decision matrix, opted in via the core helper (reference for the pattern)."""
105
+
106
+ def make(client):
107
+ mw = make_middleware(client)
108
+ ran = {}
109
+
110
+ def handler(request):
111
+ ran["yes"] = True
112
+ return ToolMessage(content="ran", tool_call_id="call-1")
113
+
114
+ return GateCase(
115
+ invoke=lambda: mw.wrap_tool_call(make_request("target"), handler),
116
+ tool_ran=lambda: ran.get("yes", False),
117
+ vetoed=lambda out: isinstance(out, ToolMessage) and out.status == "error",
118
+ )
119
+
120
+ run_gate_matrix(make, harness="langchain")
121
+
122
+
123
+ def test_interactive_tool_passes_through(monkeypatch):
124
+ monkeypatch.setenv("ORY_INTERACTIVE_TOOLS", "AskUser")
125
+ client = create_mock_client()
126
+ mw = make_middleware(client)
127
+ out = mw.wrap_tool_call(make_request("AskUser"), _handler_returns(ToolMessage(content="asked", tool_call_id="call-1")))
128
+ assert out.content == "asked"
129
+ assert get_trace_spans(client, "user.interaction")
130
+
131
+
132
+ def test_session_start_runs_once():
133
+ client = create_mock_client()
134
+ stub_relationship_ok(client)
135
+ stub_permission_allowed(client)
136
+ calls = {"n": 0}
137
+
138
+ def fake_user_login(c, **kw):
139
+ calls["n"] += 1
140
+ c.set_user_principal(PrincipalIdentity(subject="user:alice", token="t"))
141
+ return UserLoginDecision(True, "ok", "ok", subject="user:alice")
142
+
143
+ def fake_agent_gate(c, **kw):
144
+ c.set_agent_principal(PrincipalIdentity(subject="agent:cid", token="a"))
145
+ return AgentCredentials(kind="dynamic", subject="agent:cid", token="a", reason="ok")
146
+
147
+ mw = OryMiddleware(client=client) # session gate NOT pre-marked here
148
+ from ory_argus import adapters
149
+
150
+ orig = adapters.session_start
151
+ try:
152
+ adapters.session_start = lambda c, **kw: orig(c, user_login=fake_user_login, agent_gate=fake_agent_gate, **{k: v for k, v in kw.items() if k not in ("user_login", "agent_gate")})
153
+ # also patch the name imported into middleware module
154
+ import ory_langchain.middleware as m
155
+ m.session_start = adapters.session_start
156
+ r1 = mw.wrap_tool_call(make_request("search"), _handler_returns(ToolMessage(content="1", tool_call_id="c1")))
157
+ r2 = mw.wrap_tool_call(make_request("search"), _handler_returns(ToolMessage(content="2", tool_call_id="c2")))
158
+ finally:
159
+ adapters.session_start = orig
160
+ import ory_langchain.middleware as m
161
+ m.session_start = orig
162
+
163
+ assert r1.content == "1" and r2.content == "2"
164
+ assert calls["n"] == 1 # session gates ran exactly once
165
+ assert get_trace_spans(client, "relationship.create")
166
+
167
+
168
+ def test_callback_handler_traces():
169
+ client = create_mock_client()
170
+ cb = OryCallbackHandler(client=client)
171
+ cb.on_tool_start({"name": "search"}, "query")
172
+ cb.on_tool_end("result")
173
+ invoke = get_trace_spans(client, "tool.invoke")[0]
174
+ assert invoke.attributes["source"] == "callback"
175
+ assert get_trace_spans(client, "tool.complete")
@@ -0,0 +1,66 @@
1
+ """Real-SDK test: drive the middleware with a real LangChain ``ToolCallRequest``.
2
+
3
+ LangChain is a dependency of this package and is installed in the workspace venv, so this
4
+ runs in the normal suite too. Complements ``test_middleware.py`` (which builds the request by
5
+ hand) by asserting the real ``ToolCallRequest`` shape extracts correctly and that a deny
6
+ produces a real ``ToolMessage`` veto. The permission decision is stubbed (no backend).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+
13
+ pytest.importorskip("langchain")
14
+
15
+ from langchain_core.messages import ToolMessage # noqa: E402
16
+ from langgraph.prebuilt.tool_node import ToolCallRequest # noqa: E402
17
+
18
+ from ory_argus.adapters import GateResult # noqa: E402
19
+ from ory_argus.permissions import PermissionDecision # noqa: E402
20
+ from ory_argus.testing import create_mock_client # noqa: E402
21
+ from ory_langchain import OryMiddleware # noqa: E402
22
+ from ory_langchain import middleware as m # noqa: E402
23
+
24
+
25
+ def _request(name):
26
+ return ToolCallRequest(
27
+ tool_call={"name": name, "args": {"x": 1}, "id": "c", "type": "tool_call"},
28
+ tool=None, state={}, runtime=None,
29
+ )
30
+
31
+
32
+ def _handler(_request):
33
+ return ToolMessage(content="RAN", tool_call_id="x")
34
+
35
+
36
+ def _stub_gate(monkeypatch, *, blocked, captured=None):
37
+ monkeypatch.setattr(m, "session_start", lambda *a, **k: None)
38
+
39
+ def fake_gate(client, **kw):
40
+ if captured is not None:
41
+ captured["tool_name"] = kw.get("tool_name")
42
+ return GateResult(
43
+ proceed=not blocked, blocked=blocked,
44
+ decision=PermissionDecision(kind="deny" if blocked else "allow"),
45
+ subject="User:t", namespace="AgentTools",
46
+ denial_message="Ory: permission denied" if blocked else None,
47
+ )
48
+
49
+ monkeypatch.setattr(m, "gate", fake_gate)
50
+
51
+
52
+ def test_real_request_extraction_and_deny(monkeypatch):
53
+ captured = {}
54
+ _stub_gate(monkeypatch, blocked=True, captured=captured)
55
+ mw = OryMiddleware(client=create_mock_client(harness="langchain"))
56
+ out = mw.wrap_tool_call(_request("MyRealTool"), _handler)
57
+ assert captured["tool_name"] == "MyRealTool" # extracted from the real ToolCallRequest
58
+ assert isinstance(out, ToolMessage) and out.status == "error"
59
+ assert "permission denied" in out.content
60
+
61
+
62
+ def test_real_request_runs_on_allow(monkeypatch):
63
+ _stub_gate(monkeypatch, blocked=False)
64
+ mw = OryMiddleware(client=create_mock_client(harness="langchain"))
65
+ out = mw.wrap_tool_call(_request("MyRealTool"), _handler)
66
+ assert isinstance(out, ToolMessage) and out.content == "RAN"