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.
@@ -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