ory-crewai 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_crewai-0.13.9/.gitignore +36 -0
- ory_crewai-0.13.9/PKG-INFO +45 -0
- ory_crewai-0.13.9/README.md +30 -0
- ory_crewai-0.13.9/pyproject.toml +23 -0
- ory_crewai-0.13.9/src/ory_crewai/__init__.py +14 -0
- ory_crewai-0.13.9/src/ory_crewai/tools.py +199 -0
- ory_crewai-0.13.9/tests/test_realsdk.py +89 -0
- ory_crewai-0.13.9/tests/test_tools.py +146 -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,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ory-crewai
|
|
3
|
+
Version: 0.13.9
|
|
4
|
+
Summary: Ory Agent Security for CrewAI — per-tool authorization, tracing, and identity propagation by wrapping tools. Built on ory-argus.
|
|
5
|
+
Author: Ory
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: agent,ai,authorization,crewai,ory,permissions
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: crewai>=0.80
|
|
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-crewai
|
|
17
|
+
|
|
18
|
+
Ory Agent Security for [CrewAI](https://docs.crewai.com).
|
|
19
|
+
|
|
20
|
+
Wraps your CrewAI tools so every call is authorized against Ory Permissions, traced, and
|
|
21
|
+
tied to the user → agent identity — built on [`ory-argus`](https://pypi.org/project/ory-argus/).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install ory-crewai
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from crewai import Agent
|
|
29
|
+
from ory_crewai import guard_tools
|
|
30
|
+
|
|
31
|
+
agent = Agent(role="researcher", tools=guard_tools([search, send_email]))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In **enforce** mode (`ORY_PERMISSION_MODE=enforce`) a denied tool raises an error that CrewAI
|
|
35
|
+
surfaces to the LLM; the tool never runs. In **observe** mode (default) the tool runs and a
|
|
36
|
+
`permission.observe_deny` span is recorded.
|
|
37
|
+
|
|
38
|
+
For tracing across paths that bypass the wrapped tools, also register the event listener:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from ory_crewai import ory_event_listener
|
|
42
|
+
listener = ory_event_listener() # traces ToolUsage events
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Credentials come from the shared `~/.config/ory-agent-plugins/config.json`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ory-crewai
|
|
2
|
+
|
|
3
|
+
Ory Agent Security for [CrewAI](https://docs.crewai.com).
|
|
4
|
+
|
|
5
|
+
Wraps your CrewAI tools so every call is authorized against Ory Permissions, traced, and
|
|
6
|
+
tied to the user → agent identity — built on [`ory-argus`](https://pypi.org/project/ory-argus/).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install ory-crewai
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from crewai import Agent
|
|
14
|
+
from ory_crewai import guard_tools
|
|
15
|
+
|
|
16
|
+
agent = Agent(role="researcher", tools=guard_tools([search, send_email]))
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
In **enforce** mode (`ORY_PERMISSION_MODE=enforce`) a denied tool raises an error that CrewAI
|
|
20
|
+
surfaces to the LLM; the tool never runs. In **observe** mode (default) the tool runs and a
|
|
21
|
+
`permission.observe_deny` span is recorded.
|
|
22
|
+
|
|
23
|
+
For tracing across paths that bypass the wrapped tools, also register the event listener:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from ory_crewai import ory_event_listener
|
|
27
|
+
listener = ory_event_listener() # traces ToolUsage events
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Credentials come from the shared `~/.config/ory-agent-plugins/config.json`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ory-crewai"
|
|
7
|
+
version = "0.13.9"
|
|
8
|
+
description = "Ory Agent Security for CrewAI — per-tool authorization, tracing, and identity propagation by wrapping tools. 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", "crewai", "authorization", "agent", "ai", "permissions"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ory-argus>=0.8,<1",
|
|
16
|
+
"crewai>=0.80",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/ory_crewai"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Ory Agent Security for CrewAI.
|
|
2
|
+
|
|
3
|
+
from ory_crewai import guard_tools
|
|
4
|
+
|
|
5
|
+
agent = Agent(role="…", tools=guard_tools([search, send_email]))
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .tools import guard_tool_arun, guard_tool_run, guard_tools, ory_event_listener
|
|
11
|
+
|
|
12
|
+
__version__ = "0.13.9"
|
|
13
|
+
|
|
14
|
+
__all__ = ["guard_tools", "guard_tool_run", "guard_tool_arun", "ory_event_listener", "__version__"]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Ory Agent Security for CrewAI.
|
|
2
|
+
|
|
3
|
+
CrewAI has no global before-tool veto — the per-tool gate points are the tool's ``_run``
|
|
4
|
+
(sync, via ``run``) and ``_arun`` (async, via ``arun``). ``guard_tools(tools)`` wraps both
|
|
5
|
+
so every call is authorized against Ory Permissions, traced, and (on first call) preceded
|
|
6
|
+
by the Ory session gates:
|
|
7
|
+
|
|
8
|
+
- allow / observe / fail-open / interactive → run the tool and record ``tool.complete``.
|
|
9
|
+
- deny (enforce) → raise ``OryDenialError`` so CrewAI surfaces the denial to the LLM and
|
|
10
|
+
the tool never runs.
|
|
11
|
+
|
|
12
|
+
For agents/tasks that bypass the wrapped tools, pair this with :func:`ory_event_listener`
|
|
13
|
+
(trace-only, via CrewAI's event bus). Gate logic lives in ``ory_argus``; ``crewai`` is
|
|
14
|
+
imported lazily.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import inspect
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ory_argus import (
|
|
24
|
+
DenialContext,
|
|
25
|
+
GateResult,
|
|
26
|
+
OryAgentClient,
|
|
27
|
+
OryDenialError,
|
|
28
|
+
complete,
|
|
29
|
+
gate,
|
|
30
|
+
session_start,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
HARNESS = "crewai"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _gate_or_deny(
|
|
37
|
+
client: OryAgentClient,
|
|
38
|
+
*,
|
|
39
|
+
tool_name: str,
|
|
40
|
+
args: tuple,
|
|
41
|
+
kwargs: dict,
|
|
42
|
+
can_block: bool,
|
|
43
|
+
project_url: str | None,
|
|
44
|
+
state: dict,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Run session-start (once) + the permission gate; raise OryDenialError on enforce-deny."""
|
|
47
|
+
if not state.get("started"):
|
|
48
|
+
state["started"] = True
|
|
49
|
+
try:
|
|
50
|
+
session_start(client, harness=HARNESS, project_url=project_url)
|
|
51
|
+
except Exception as err: # noqa: BLE001
|
|
52
|
+
client.logger.warn("session_start.failed", {"message": str(err)})
|
|
53
|
+
|
|
54
|
+
result: GateResult = gate(
|
|
55
|
+
client, harness=HARNESS, tool_name=tool_name, tool_args={"args": args, "kwargs": kwargs}, can_block=can_block
|
|
56
|
+
)
|
|
57
|
+
if result.blocked:
|
|
58
|
+
raise OryDenialError(
|
|
59
|
+
DenialContext(tool=tool_name, subject_id=result.subject, namespace=result.namespace)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def guard_tool_run(
|
|
64
|
+
client: OryAgentClient,
|
|
65
|
+
*,
|
|
66
|
+
tool_name: str,
|
|
67
|
+
run: Any,
|
|
68
|
+
args: tuple,
|
|
69
|
+
kwargs: dict,
|
|
70
|
+
can_block: bool = True,
|
|
71
|
+
project_url: str | None = None,
|
|
72
|
+
_session_state: dict | None = None,
|
|
73
|
+
) -> Any:
|
|
74
|
+
"""SDK-free gate-then-run for a single tool ``_run`` call."""
|
|
75
|
+
state = _session_state if _session_state is not None else {}
|
|
76
|
+
_gate_or_deny(
|
|
77
|
+
client, tool_name=tool_name, args=args, kwargs=kwargs,
|
|
78
|
+
can_block=can_block, project_url=project_url, state=state,
|
|
79
|
+
)
|
|
80
|
+
output = run(*args, **kwargs)
|
|
81
|
+
complete(client, tool_name=tool_name, output=output)
|
|
82
|
+
return output
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def guard_tool_arun(
|
|
86
|
+
client: OryAgentClient,
|
|
87
|
+
*,
|
|
88
|
+
tool_name: str,
|
|
89
|
+
run: Any,
|
|
90
|
+
args: tuple,
|
|
91
|
+
kwargs: dict,
|
|
92
|
+
can_block: bool = True,
|
|
93
|
+
project_url: str | None = None,
|
|
94
|
+
_session_state: dict | None = None,
|
|
95
|
+
) -> Any:
|
|
96
|
+
"""SDK-free gate-then-run for a single tool ``_arun`` call (async path via ``arun``).
|
|
97
|
+
|
|
98
|
+
Same semantics as :func:`guard_tool_run` — the gate runs (and an enforce-deny raises
|
|
99
|
+
``OryDenialError``) BEFORE the tool body executes; observe/fail-open proceed with spans.
|
|
100
|
+
"""
|
|
101
|
+
state = _session_state if _session_state is not None else {}
|
|
102
|
+
_gate_or_deny(
|
|
103
|
+
client, tool_name=tool_name, args=args, kwargs=kwargs,
|
|
104
|
+
can_block=can_block, project_url=project_url, state=state,
|
|
105
|
+
)
|
|
106
|
+
output = run(*args, **kwargs)
|
|
107
|
+
if inspect.isawaitable(output):
|
|
108
|
+
output = await output
|
|
109
|
+
complete(client, tool_name=tool_name, output=output)
|
|
110
|
+
return output
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _warn_wrap_failed(client: OryAgentClient, event: str, tool_name: str, err: Exception) -> None:
|
|
114
|
+
"""Wrapping failed: the tool will run UNGATED. Log + always warn on stderr (never stdout)."""
|
|
115
|
+
client.logger.warn(event, {"tool": tool_name, "message": str(err)})
|
|
116
|
+
print(
|
|
117
|
+
f"[ory] WARNING: failed to wrap tool '{tool_name}' ({err}); "
|
|
118
|
+
"it will run UNGATED by Ory permission checks.",
|
|
119
|
+
file=sys.stderr,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def guard_tools(
|
|
124
|
+
tools: list,
|
|
125
|
+
*,
|
|
126
|
+
client: OryAgentClient | None = None,
|
|
127
|
+
can_block: bool = True,
|
|
128
|
+
project_url: str | None = None,
|
|
129
|
+
) -> list:
|
|
130
|
+
"""Wrap each CrewAI tool's ``_run`` and ``_arun`` with the Ory gate. Returns the same list (mutated)."""
|
|
131
|
+
c = client or OryAgentClient.from_env(HARNESS)
|
|
132
|
+
state: dict = {}
|
|
133
|
+
for tool in tools:
|
|
134
|
+
tool_name = getattr(tool, "name", None) or type(tool).__name__
|
|
135
|
+
|
|
136
|
+
def _make(run, name):
|
|
137
|
+
def _wrapped(*args, **kwargs):
|
|
138
|
+
return guard_tool_run(
|
|
139
|
+
c, tool_name=name, run=run, args=args, kwargs=kwargs,
|
|
140
|
+
can_block=can_block, project_url=project_url, _session_state=state,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return _wrapped
|
|
144
|
+
|
|
145
|
+
def _make_async(run, name):
|
|
146
|
+
async def _awrapped(*args, **kwargs):
|
|
147
|
+
return await guard_tool_arun(
|
|
148
|
+
c, tool_name=name, run=run, args=args, kwargs=kwargs,
|
|
149
|
+
can_block=can_block, project_url=project_url, _session_state=state,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return _awrapped
|
|
153
|
+
|
|
154
|
+
# Sync path: BaseTool.run() → _run.
|
|
155
|
+
original = getattr(tool, "_run", None)
|
|
156
|
+
if callable(original):
|
|
157
|
+
try:
|
|
158
|
+
tool._run = _make(original, tool_name) # type: ignore[attr-defined]
|
|
159
|
+
except Exception as err: # noqa: BLE001 — some tool models are frozen
|
|
160
|
+
_warn_wrap_failed(c, "crewai.wrap_tool.failed", tool_name, err)
|
|
161
|
+
|
|
162
|
+
# Async path: BaseTool.arun() awaits _arun — a tool overriding _arun would
|
|
163
|
+
# otherwise execute ungated (wrapping _run alone doesn't cover it).
|
|
164
|
+
original_arun = getattr(tool, "_arun", None)
|
|
165
|
+
if callable(original_arun):
|
|
166
|
+
try:
|
|
167
|
+
tool._arun = _make_async(original_arun, tool_name) # type: ignore[attr-defined]
|
|
168
|
+
except Exception as err: # noqa: BLE001 — some tool models are frozen
|
|
169
|
+
_warn_wrap_failed(c, "crewai.wrap_async_tool.failed", tool_name, err)
|
|
170
|
+
return tools
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ory_event_listener(*, client: OryAgentClient | None = None):
|
|
174
|
+
"""Build a CrewAI event-bus listener that traces tool usage (observation only)."""
|
|
175
|
+
from crewai.utilities.events import ( # type: ignore
|
|
176
|
+
ToolUsageFinishedEvent,
|
|
177
|
+
ToolUsageStartedEvent,
|
|
178
|
+
)
|
|
179
|
+
from crewai.utilities.events.base_event_listener import BaseEventListener # type: ignore
|
|
180
|
+
|
|
181
|
+
c = client or OryAgentClient.from_env(HARNESS)
|
|
182
|
+
|
|
183
|
+
class _OryEventListener(BaseEventListener): # type: ignore[misc]
|
|
184
|
+
def setup_listeners(self, bus):
|
|
185
|
+
@bus.on(ToolUsageStartedEvent)
|
|
186
|
+
def _started(source, event): # noqa: ANN001
|
|
187
|
+
c.tracer.record(
|
|
188
|
+
"tool.invoke", "ok",
|
|
189
|
+
attributes={"toolName": getattr(event, "tool_name", "unknown"), "source": "event"},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@bus.on(ToolUsageFinishedEvent)
|
|
193
|
+
def _finished(source, event): # noqa: ANN001
|
|
194
|
+
c.tracer.record("tool.complete", "ok", attributes={"source": "event"})
|
|
195
|
+
|
|
196
|
+
return _OryEventListener()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
__all__ = ["HARNESS", "guard_tool_arun", "guard_tool_run", "guard_tools", "ory_event_listener"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Real-SDK test: wrap an actual CrewAI ``BaseTool`` and enforce.
|
|
2
|
+
|
|
3
|
+
Runs only when ``crewai`` is importable (``pytest.importorskip``), so the default workspace
|
|
4
|
+
venv skips it; the driver (``scripts/realsdk-tests.sh`` / ``pnpm test:realsdk``) runs it in an
|
|
5
|
+
isolated env that installs CrewAI. Complements ``test_tools.py`` (which uses a fake tool):
|
|
6
|
+
this validates the thing a fake can't — that wrapping a *real* (pydantic-based) ``BaseTool``
|
|
7
|
+
instance and short-circuiting its ``_run`` actually works. The permission decision is stubbed
|
|
8
|
+
(no backend) so the test isolates the SDK translation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
pytest.importorskip("crewai")
|
|
18
|
+
|
|
19
|
+
from crewai.tools import BaseTool # noqa: E402
|
|
20
|
+
|
|
21
|
+
from ory_argus.adapters import GateResult # noqa: E402
|
|
22
|
+
from ory_argus.denial import OryDenialError # noqa: E402
|
|
23
|
+
from ory_argus.permissions import PermissionDecision # noqa: E402
|
|
24
|
+
from ory_argus.testing import create_mock_client # noqa: E402
|
|
25
|
+
from ory_crewai import guard_tools # noqa: E402
|
|
26
|
+
from ory_crewai import tools as tools_mod # noqa: E402
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MyRealTool(BaseTool):
|
|
30
|
+
name: str = "MyRealTool"
|
|
31
|
+
description: str = "a real crewai tool"
|
|
32
|
+
|
|
33
|
+
def _run(self, *args, **kwargs):
|
|
34
|
+
return "RAN"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MyRealAsyncTool(BaseTool):
|
|
38
|
+
name: str = "MyRealAsyncTool"
|
|
39
|
+
description: str = "a real crewai tool with an async _arun"
|
|
40
|
+
|
|
41
|
+
def _run(self, *args, **kwargs):
|
|
42
|
+
return "RAN"
|
|
43
|
+
|
|
44
|
+
async def _arun(self, *args, **kwargs):
|
|
45
|
+
return "ARAN"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _stub_gate(monkeypatch, *, blocked):
|
|
49
|
+
monkeypatch.setattr(tools_mod, "session_start", lambda *a, **k: None)
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
tools_mod, "gate",
|
|
52
|
+
lambda client, **kw: GateResult(
|
|
53
|
+
proceed=not blocked, blocked=blocked,
|
|
54
|
+
decision=PermissionDecision(kind="deny" if blocked else "allow"),
|
|
55
|
+
subject="User:t", namespace="AgentTools",
|
|
56
|
+
denial_message="Ory: permission denied" if blocked else None,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_real_basetool_blocked_on_deny(monkeypatch):
|
|
62
|
+
_stub_gate(monkeypatch, blocked=True)
|
|
63
|
+
tool = MyRealTool()
|
|
64
|
+
guard_tools([tool], client=create_mock_client(harness="crewai"))
|
|
65
|
+
with pytest.raises(OryDenialError):
|
|
66
|
+
tool._run() # wrapper must have replaced _run on the real BaseTool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_real_basetool_runs_on_allow(monkeypatch):
|
|
70
|
+
_stub_gate(monkeypatch, blocked=False)
|
|
71
|
+
tool = MyRealTool()
|
|
72
|
+
guard_tools([tool], client=create_mock_client(harness="crewai"))
|
|
73
|
+
assert tool._run() == "RAN"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_real_basetool_arun_blocked_on_deny(monkeypatch):
|
|
77
|
+
# BaseTool.arun awaits self._arun — a tool overriding _arun must be gated too.
|
|
78
|
+
_stub_gate(monkeypatch, blocked=True)
|
|
79
|
+
tool = MyRealAsyncTool()
|
|
80
|
+
guard_tools([tool], client=create_mock_client(harness="crewai"))
|
|
81
|
+
with pytest.raises(OryDenialError):
|
|
82
|
+
asyncio.run(tool._arun()) # wrapper must have replaced _arun on the real BaseTool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_real_basetool_arun_runs_on_allow(monkeypatch):
|
|
86
|
+
_stub_gate(monkeypatch, blocked=False)
|
|
87
|
+
tool = MyRealAsyncTool()
|
|
88
|
+
guard_tools([tool], client=create_mock_client(harness="crewai"))
|
|
89
|
+
assert asyncio.run(tool._arun()) == "ARAN"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Hermetic tests for the CrewAI tool-wrap gate (SDK-free).
|
|
2
|
+
|
|
3
|
+
Covers the native veto shape (``OryDenialError`` raised from the wrapped ``_run``) and
|
|
4
|
+
request-shape delegation (``tool.name`` + verbatim args), plus the shared session sequence
|
|
5
|
+
across a guarded tool list. The full permission decision matrix is core-owned
|
|
6
|
+
(``ory-argus/tests/test_adapters.py``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from ory_argus.client import PrincipalIdentity
|
|
16
|
+
from ory_argus.denial import OryDenialError
|
|
17
|
+
from ory_argus.testing import (
|
|
18
|
+
create_mock_client,
|
|
19
|
+
get_trace_spans,
|
|
20
|
+
stub_permission_allowed,
|
|
21
|
+
stub_permission_denied,
|
|
22
|
+
)
|
|
23
|
+
from ory_crewai import guard_tools # type: ignore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(autouse=True)
|
|
27
|
+
def _isolate(monkeypatch, tmp_path):
|
|
28
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
29
|
+
monkeypatch.delenv("ORY_PERMISSION_MODE", raising=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeTool:
|
|
33
|
+
def __init__(self, name):
|
|
34
|
+
self.name = name
|
|
35
|
+
self.ran = []
|
|
36
|
+
|
|
37
|
+
def _run(self, *args, **kwargs):
|
|
38
|
+
self.ran.append((args, kwargs))
|
|
39
|
+
return f"ran:{self.name}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FakeAsyncTool(FakeTool):
|
|
43
|
+
"""Mirrors a CrewAI BaseTool that overrides _arun (the arun() path)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, name):
|
|
46
|
+
super().__init__(name)
|
|
47
|
+
self.aran = []
|
|
48
|
+
|
|
49
|
+
async def _arun(self, *args, **kwargs):
|
|
50
|
+
self.aran.append((args, kwargs))
|
|
51
|
+
return f"aran:{self.name}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def client_with_user():
|
|
55
|
+
c = create_mock_client(harness="crewai")
|
|
56
|
+
c.set_user_principal(PrincipalIdentity(subject="user:alice"))
|
|
57
|
+
return c
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_allow_runs_and_completes():
|
|
61
|
+
c = client_with_user()
|
|
62
|
+
stub_permission_allowed(c)
|
|
63
|
+
tool = FakeTool("search")
|
|
64
|
+
guard_tools([tool], client=c)
|
|
65
|
+
out = tool._run("q", k=1)
|
|
66
|
+
assert out == "ran:search"
|
|
67
|
+
assert tool.ran == [(("q",), {"k": 1})]
|
|
68
|
+
invoke = get_trace_spans(c, "tool.invoke")[0]
|
|
69
|
+
assert invoke.attributes["allowed"] is True
|
|
70
|
+
# Tool name pulled from the BaseTool-shaped ``name`` attribute.
|
|
71
|
+
assert invoke.attributes["toolName"] == "search"
|
|
72
|
+
assert get_trace_spans(c, "tool.complete")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_enforce_raises_and_skips(monkeypatch):
|
|
76
|
+
monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
|
|
77
|
+
c = client_with_user()
|
|
78
|
+
stub_permission_denied(c)
|
|
79
|
+
tool = FakeTool("rm")
|
|
80
|
+
guard_tools([tool], client=c)
|
|
81
|
+
# Native veto shape: the wrapped _run raises OryDenialError at the tool boundary.
|
|
82
|
+
with pytest.raises(OryDenialError):
|
|
83
|
+
tool._run()
|
|
84
|
+
assert tool.ran == [] # underlying tool never invoked
|
|
85
|
+
assert get_trace_spans(c, "tool.block")[0].attributes["blocked"] is True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_async_enforce_raises_and_skips(monkeypatch):
|
|
89
|
+
monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
|
|
90
|
+
c = client_with_user()
|
|
91
|
+
stub_permission_denied(c)
|
|
92
|
+
tool = FakeAsyncTool("rm")
|
|
93
|
+
guard_tools([tool], client=c)
|
|
94
|
+
with pytest.raises(OryDenialError):
|
|
95
|
+
asyncio.run(tool._arun())
|
|
96
|
+
assert tool.aran == []
|
|
97
|
+
assert get_trace_spans(c, "tool.block")[0].attributes["blocked"] is True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_async_allow_runs_and_completes():
|
|
101
|
+
c = client_with_user()
|
|
102
|
+
stub_permission_allowed(c)
|
|
103
|
+
tool = FakeAsyncTool("search")
|
|
104
|
+
guard_tools([tool], client=c)
|
|
105
|
+
out = asyncio.run(tool._arun("q", k=1))
|
|
106
|
+
assert out == "aran:search"
|
|
107
|
+
assert tool.aran == [(("q",), {"k": 1})]
|
|
108
|
+
assert get_trace_spans(c, "tool.invoke")[0].attributes["allowed"] is True
|
|
109
|
+
assert get_trace_spans(c, "tool.complete")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_async_observe_runs_tool():
|
|
113
|
+
c = client_with_user()
|
|
114
|
+
stub_permission_denied(c)
|
|
115
|
+
tool = FakeAsyncTool("rm")
|
|
116
|
+
guard_tools([tool], client=c)
|
|
117
|
+
assert asyncio.run(tool._arun()) == "aran:rm"
|
|
118
|
+
assert get_trace_spans(c, "permission.observe_deny")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_wrap_failure_warns_on_stderr(capsys):
|
|
122
|
+
c = client_with_user()
|
|
123
|
+
stub_permission_allowed(c)
|
|
124
|
+
|
|
125
|
+
class FrozenTool:
|
|
126
|
+
name = "frozen"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def _run(self):
|
|
130
|
+
return lambda: "x"
|
|
131
|
+
|
|
132
|
+
guard_tools([FrozenTool()], client=c)
|
|
133
|
+
err = capsys.readouterr().err
|
|
134
|
+
assert "UNGATED" in err
|
|
135
|
+
assert "frozen" in err
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_session_start_once_across_tools():
|
|
139
|
+
c = client_with_user()
|
|
140
|
+
stub_permission_allowed(c)
|
|
141
|
+
t1, t2 = FakeTool("a"), FakeTool("b")
|
|
142
|
+
guard_tools([t1, t2], client=c)
|
|
143
|
+
t1._run()
|
|
144
|
+
t2._run()
|
|
145
|
+
# Two invoke spans, but the gate sequence is shared (no crash on second).
|
|
146
|
+
assert len(get_trace_spans(c, "tool.invoke")) == 2
|