ory-strands 0.12.1__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_strands-0.12.1/.gitignore +36 -0
- ory_strands-0.12.1/PKG-INFO +43 -0
- ory_strands-0.12.1/README.md +28 -0
- ory_strands-0.12.1/pyproject.toml +23 -0
- ory_strands-0.12.1/src/ory_strands/__init__.py +15 -0
- ory_strands-0.12.1/src/ory_strands/hooks.py +115 -0
- ory_strands-0.12.1/tests/test_hooks.py +70 -0
- ory_strands-0.12.1/tests/test_realsdk.py +35 -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,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ory-strands
|
|
3
|
+
Version: 0.12.1
|
|
4
|
+
Summary: Ory Agent Security for AWS Strands Agents — per-tool authorization, tracing, and identity propagation via tool-invocation hooks. Built on ory-argus.
|
|
5
|
+
Author: Ory
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: agent,ai,authorization,aws,bedrock,ory,permissions,strands
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: ory-argus<1,>=0.8
|
|
10
|
+
Requires-Dist: strands-agents>=1.0
|
|
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-strands
|
|
17
|
+
|
|
18
|
+
Ory Agent Security for [AWS Strands Agents](https://strandsagents.com/).
|
|
19
|
+
|
|
20
|
+
Authorizes every tool call against Ory Permissions via Strands' tool-invocation hooks,
|
|
21
|
+
traces invocations, and propagates user → agent identity — built on
|
|
22
|
+
[`ory-argus`](https://pypi.org/project/ory-argus/).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ory-strands
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from strands import Agent
|
|
30
|
+
from ory_strands import OryHooks
|
|
31
|
+
|
|
32
|
+
agent = Agent(model=..., tools=[search, send_email], hooks=[OryHooks()])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`OryHooks` subscribes to `BeforeToolCallEvent` (authorize) and
|
|
36
|
+
`AfterToolCallEvent` (trace). In **enforce** mode (`ORY_PERMISSION_MODE=enforce`) a
|
|
37
|
+
denied tool is **cancelled** — Strands returns the denial as an error tool result and the
|
|
38
|
+
tool never runs. In **observe** mode (default) the tool runs and a `permission.observe_deny`
|
|
39
|
+
span is recorded.
|
|
40
|
+
|
|
41
|
+
Credentials come from the shared `~/.config/ory-agent-plugins/config.json`, so a login from
|
|
42
|
+
any Ory harness or `ory-argus login` is reused automatically. See
|
|
43
|
+
[docs/sdk-integrations.md](https://github.com/ory-corp/ory-agent-plugins/blob/main/docs/sdk-integrations.md).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# ory-strands
|
|
2
|
+
|
|
3
|
+
Ory Agent Security for [AWS Strands Agents](https://strandsagents.com/).
|
|
4
|
+
|
|
5
|
+
Authorizes every tool call against Ory Permissions via Strands' tool-invocation hooks,
|
|
6
|
+
traces invocations, and propagates user → agent identity — built on
|
|
7
|
+
[`ory-argus`](https://pypi.org/project/ory-argus/).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ory-strands
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from strands import Agent
|
|
15
|
+
from ory_strands import OryHooks
|
|
16
|
+
|
|
17
|
+
agent = Agent(model=..., tools=[search, send_email], hooks=[OryHooks()])
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`OryHooks` subscribes to `BeforeToolCallEvent` (authorize) and
|
|
21
|
+
`AfterToolCallEvent` (trace). In **enforce** mode (`ORY_PERMISSION_MODE=enforce`) a
|
|
22
|
+
denied tool is **cancelled** — Strands returns the denial as an error tool result and the
|
|
23
|
+
tool never runs. In **observe** mode (default) the tool runs and a `permission.observe_deny`
|
|
24
|
+
span is recorded.
|
|
25
|
+
|
|
26
|
+
Credentials come from the shared `~/.config/ory-agent-plugins/config.json`, so a login from
|
|
27
|
+
any Ory harness or `ory-argus login` is reused automatically. See
|
|
28
|
+
[docs/sdk-integrations.md](https://github.com/ory-corp/ory-agent-plugins/blob/main/docs/sdk-integrations.md).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ory-strands"
|
|
7
|
+
version = "0.12.1"
|
|
8
|
+
description = "Ory Agent Security for AWS Strands Agents — per-tool authorization, tracing, and identity propagation via tool-invocation hooks. 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", "strands", "aws", "bedrock", "authorization", "agent", "ai", "permissions"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ory-argus>=0.8,<1",
|
|
16
|
+
"strands-agents>=1.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/ory_strands"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Ory Agent Security for AWS Strands Agents.
|
|
2
|
+
|
|
3
|
+
from strands import Agent
|
|
4
|
+
from ory_strands import OryHooks
|
|
5
|
+
|
|
6
|
+
agent = Agent(model=..., tools=[...], hooks=[OryHooks()])
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .hooks import OryHooks, apply_after, apply_before
|
|
12
|
+
|
|
13
|
+
__version__ = "0.12.1"
|
|
14
|
+
|
|
15
|
+
__all__ = ["OryHooks", "apply_before", "apply_after", "__version__"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Ory Agent Security for AWS Strands Agents.
|
|
2
|
+
|
|
3
|
+
Strands exposes a first-class per-tool gate through its hook system:
|
|
4
|
+
``BeforeToolCallEvent`` fires before a tool runs (and a hook can **cancel** it by setting
|
|
5
|
+
``event.cancel_tool``), and ``AfterToolCallEvent`` fires after. ``OryHooks`` is a Strands
|
|
6
|
+
``HookProvider`` that authorizes each call against Ory Permissions:
|
|
7
|
+
|
|
8
|
+
- allow / observe / fail-open / interactive → do nothing (the tool runs).
|
|
9
|
+
- deny (enforce) → set ``event.cancel_tool = "Ory: permission denied …"`` so Strands skips
|
|
10
|
+
the tool and returns the denial as an error tool result.
|
|
11
|
+
|
|
12
|
+
Register it on the agent:
|
|
13
|
+
|
|
14
|
+
from strands import Agent
|
|
15
|
+
from ory_strands import OryHooks
|
|
16
|
+
|
|
17
|
+
agent = Agent(model=..., tools=[...], hooks=[OryHooks()])
|
|
18
|
+
|
|
19
|
+
All gate logic lives in ``ory_argus``; the hook callbacks are duck-typed (read
|
|
20
|
+
``event.tool_use`` and set ``event.cancel_tool``), so this module imports without
|
|
21
|
+
``strands-agents`` present — only registering the hooks touches the SDK.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ory_argus import OryAgentClient, complete, gate, session_start
|
|
29
|
+
|
|
30
|
+
HARNESS = "strands"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tool_name(event: Any) -> str:
|
|
34
|
+
tu = getattr(event, "tool_use", None) or {}
|
|
35
|
+
try:
|
|
36
|
+
return tu.get("name") or "unknown"
|
|
37
|
+
except AttributeError:
|
|
38
|
+
return getattr(tu, "name", None) or "unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tool_input(event: Any) -> Any:
|
|
42
|
+
tu = getattr(event, "tool_use", None) or {}
|
|
43
|
+
try:
|
|
44
|
+
return tu.get("input")
|
|
45
|
+
except AttributeError:
|
|
46
|
+
return getattr(tu, "input", None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def apply_before(
|
|
50
|
+
client: OryAgentClient,
|
|
51
|
+
event: Any,
|
|
52
|
+
*,
|
|
53
|
+
can_block: bool = True,
|
|
54
|
+
project_url: str | None = None,
|
|
55
|
+
_session_state: dict | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Gate a ``BeforeToolCallEvent``. On deny, sets ``event.cancel_tool``. SDK-free."""
|
|
58
|
+
state = _session_state if _session_state is not None else {}
|
|
59
|
+
if not state.get("started"):
|
|
60
|
+
state["started"] = True
|
|
61
|
+
try:
|
|
62
|
+
session_start(client, harness=HARNESS, project_url=project_url)
|
|
63
|
+
except Exception as err: # noqa: BLE001 — session gate must never break the agent
|
|
64
|
+
client.logger.warn("session_start.failed", {"message": str(err)})
|
|
65
|
+
|
|
66
|
+
result = gate(
|
|
67
|
+
client, harness=HARNESS, tool_name=_tool_name(event), tool_args=_tool_input(event), can_block=can_block
|
|
68
|
+
)
|
|
69
|
+
if result.blocked:
|
|
70
|
+
event.cancel_tool = result.denial_message or "Ory: permission denied"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def apply_after(client: OryAgentClient, event: Any) -> None:
|
|
74
|
+
"""Record a ``tool.complete`` span for an ``AfterToolCallEvent``. SDK-free."""
|
|
75
|
+
complete(client, tool_name=_tool_name(event), output=getattr(event, "result", None))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OryHooks:
|
|
79
|
+
"""Strands ``HookProvider`` that gates every tool call through Ory.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
client: An ``OryAgentClient``. Defaults to ``OryAgentClient.from_env("strands")``.
|
|
83
|
+
can_block: Whether a denied tool is hard-cancelled. Default ``True``.
|
|
84
|
+
project_url: Override the Ory project URL for the session gates.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
client: OryAgentClient | None = None,
|
|
91
|
+
can_block: bool = True,
|
|
92
|
+
project_url: str | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
self._client = client or OryAgentClient.from_env(HARNESS)
|
|
95
|
+
self._can_block = can_block
|
|
96
|
+
self._project_url = project_url
|
|
97
|
+
self._state: dict = {}
|
|
98
|
+
|
|
99
|
+
def register_hooks(self, registry: Any, **kwargs: Any) -> None:
|
|
100
|
+
from strands.hooks.events import AfterToolCallEvent, BeforeToolCallEvent
|
|
101
|
+
|
|
102
|
+
registry.add_callback(BeforeToolCallEvent, self._before)
|
|
103
|
+
registry.add_callback(AfterToolCallEvent, self._after)
|
|
104
|
+
|
|
105
|
+
def _before(self, event: Any) -> None:
|
|
106
|
+
apply_before(
|
|
107
|
+
self._client, event, can_block=self._can_block,
|
|
108
|
+
project_url=self._project_url, _session_state=self._state,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _after(self, event: Any) -> None:
|
|
112
|
+
apply_after(self._client, event)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = ["HARNESS", "OryHooks", "apply_before", "apply_after"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Hermetic tests for the Strands tool-invocation hooks (mocked Ory, fake events).
|
|
2
|
+
|
|
3
|
+
Covers the native veto shape (mutating ``event.cancel_tool`` to the denial string) and
|
|
4
|
+
request-shape extraction (``tool_use["name"]`` on both the before and after events). The
|
|
5
|
+
full permission decision matrix is core-owned (``ory-argus/tests/test_adapters.py``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from ory_argus.client import PrincipalIdentity
|
|
15
|
+
from ory_argus.testing import (
|
|
16
|
+
create_mock_client,
|
|
17
|
+
get_trace_spans,
|
|
18
|
+
stub_permission_allowed,
|
|
19
|
+
stub_permission_denied,
|
|
20
|
+
)
|
|
21
|
+
from ory_strands import apply_after, apply_before
|
|
22
|
+
from ory_strands import hooks as hooks_mod
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def _isolate(monkeypatch, tmp_path):
|
|
27
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
28
|
+
monkeypatch.delenv("ORY_PERMISSION_MODE", raising=False)
|
|
29
|
+
monkeypatch.setattr(hooks_mod, "session_start", lambda *a, **k: None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def client_with_user():
|
|
33
|
+
c = create_mock_client(harness="strands")
|
|
34
|
+
c.set_user_principal(PrincipalIdentity(subject="user:alice"))
|
|
35
|
+
return c
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _before_event(name="search"):
|
|
39
|
+
return SimpleNamespace(tool_use={"name": name, "input": {"q": 1}}, cancel_tool=None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_allow_does_not_cancel():
|
|
43
|
+
c = client_with_user()
|
|
44
|
+
stub_permission_allowed(c)
|
|
45
|
+
ev = _before_event("search")
|
|
46
|
+
apply_before(c, ev)
|
|
47
|
+
assert ev.cancel_tool is None
|
|
48
|
+
invoke = get_trace_spans(c, "tool.invoke")[0]
|
|
49
|
+
assert invoke.attributes["allowed"] is True
|
|
50
|
+
# Tool name pulled from the event's ``tool_use["name"]``.
|
|
51
|
+
assert invoke.attributes["toolName"] == "search"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_enforce_cancels_tool(monkeypatch):
|
|
55
|
+
monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
|
|
56
|
+
c = client_with_user()
|
|
57
|
+
stub_permission_denied(c)
|
|
58
|
+
ev = _before_event("rm")
|
|
59
|
+
apply_before(c, ev)
|
|
60
|
+
# Native veto shape: cancel_tool mutated to the denial string, which Strands uses
|
|
61
|
+
# to cancel the invocation before the tool runs.
|
|
62
|
+
assert isinstance(ev.cancel_tool, str) and "permission denied" in ev.cancel_tool.lower()
|
|
63
|
+
assert get_trace_spans(c, "tool.block")[0].attributes["blocked"] is True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_after_records_complete():
|
|
67
|
+
c = client_with_user()
|
|
68
|
+
ev = SimpleNamespace(tool_use={"name": "search"}, result="ok")
|
|
69
|
+
apply_after(c, ev)
|
|
70
|
+
assert get_trace_spans(c, "tool.complete")[0].attributes["toolName"] == "search"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Real-SDK test: OryHooks registers against the real Strands hook events.
|
|
2
|
+
|
|
3
|
+
Runs only when ``strands`` is importable; the default workspace venv skips it. Validates the
|
|
4
|
+
thing a fake can't — that ``register_hooks`` subscribes to the *real*
|
|
5
|
+
``BeforeToolCallEvent`` / ``AfterToolCallEvent`` classes (catches an event
|
|
6
|
+
rename / moved import path). No model/backend needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
pytest.importorskip("strands")
|
|
14
|
+
|
|
15
|
+
from strands.hooks.events import AfterToolCallEvent, BeforeToolCallEvent # noqa: E402
|
|
16
|
+
|
|
17
|
+
from ory_argus.testing import create_mock_client # noqa: E402
|
|
18
|
+
from ory_strands import OryHooks # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _RecordingRegistry:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.registered: dict = {}
|
|
24
|
+
|
|
25
|
+
def add_callback(self, event_type, callback):
|
|
26
|
+
self.registered[event_type] = callback
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_register_hooks_subscribes_real_events():
|
|
30
|
+
registry = _RecordingRegistry()
|
|
31
|
+
OryHooks(client=create_mock_client(harness="strands")).register_hooks(registry)
|
|
32
|
+
assert BeforeToolCallEvent in registry.registered
|
|
33
|
+
assert AfterToolCallEvent in registry.registered
|
|
34
|
+
assert callable(registry.registered[BeforeToolCallEvent])
|
|
35
|
+
assert callable(registry.registered[AfterToolCallEvent])
|