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.
- ory_langchain-0.13.9/.gitignore +36 -0
- ory_langchain-0.13.9/PKG-INFO +66 -0
- ory_langchain-0.13.9/README.md +51 -0
- ory_langchain-0.13.9/pyproject.toml +23 -0
- ory_langchain-0.13.9/src/ory_langchain/__init__.py +23 -0
- ory_langchain-0.13.9/src/ory_langchain/callbacks.py +47 -0
- ory_langchain-0.13.9/src/ory_langchain/middleware.py +137 -0
- ory_langchain-0.13.9/tests/test_e2e.py +77 -0
- ory_langchain-0.13.9/tests/test_middleware.py +175 -0
- ory_langchain-0.13.9/tests/test_realsdk.py +66 -0
|
@@ -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"
|