ory-hermes 1.3.0__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_hermes-1.3.0/.gitignore +49 -0
- ory_hermes-1.3.0/PKG-INFO +55 -0
- ory_hermes-1.3.0/README.md +41 -0
- ory_hermes-1.3.0/pyproject.toml +22 -0
- ory_hermes-1.3.0/src/ory_hermes/__init__.py +27 -0
- ory_hermes-1.3.0/src/ory_hermes/tools.py +300 -0
- ory_hermes-1.3.0/tests/test_tools.py +195 -0
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
# Standalone deployment CLI state and decrypted credentials
|
|
20
|
+
.ory/deployment.json
|
|
21
|
+
.ory/credentials.env
|
|
22
|
+
|
|
23
|
+
# Harness sandbox dirs
|
|
24
|
+
.sandbox/
|
|
25
|
+
|
|
26
|
+
# Staged install-surface repo contents (see scripts/sync-install-surfaces.mjs)
|
|
27
|
+
.install-surfaces/
|
|
28
|
+
|
|
29
|
+
# Local dev environment (local Ory stack + Verdaccio registry)
|
|
30
|
+
.ory-dev/
|
|
31
|
+
|
|
32
|
+
# Worktrees for changes
|
|
33
|
+
.worktrees/
|
|
34
|
+
.claude/worktrees/
|
|
35
|
+
|
|
36
|
+
# Gemini CLI extension assets — materialized at install time from
|
|
37
|
+
# @ory/argus templates. The repo source is the canonical templates;
|
|
38
|
+
# these subdirs are generated wherever the install runs. The manifest and
|
|
39
|
+
# hook wiring embed absolute paths resolved on the installing machine, so
|
|
40
|
+
# they must never be committed. `GEMINI.md` is tracked source, so the
|
|
41
|
+
# extension dir itself is deliberately not ignored wholesale.
|
|
42
|
+
packages/gemini-cli/gemini-extension/skills/
|
|
43
|
+
packages/gemini-cli/gemini-extension/commands/
|
|
44
|
+
packages/gemini-cli/gemini-extension/hooks/
|
|
45
|
+
packages/gemini-cli/gemini-extension/gemini-extension.json
|
|
46
|
+
|
|
47
|
+
# Antigravity plugin bundle — same story, but the whole directory is
|
|
48
|
+
# generated (skills, commands, hooks.json, mcp_config.json, plugin.json).
|
|
49
|
+
packages/antigravity/antigravity-plugin/
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ory-hermes
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Ory Agent Security for the Hermes agent framework — per-tool authorization, activity logging, and identity propagation by wrapping the tool registry. Built on ory-argus.
|
|
5
|
+
Author: Ory
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: agent,ai,authorization,hermes,hermes-agent,nous,ory,permissions
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: ory-argus<2,>=1
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# ory-hermes
|
|
16
|
+
|
|
17
|
+
Ory Agent Security for the [Hermes agent framework](https://github.com/NousResearch/hermes-agent).
|
|
18
|
+
|
|
19
|
+
Wraps your Hermes tool registry so every call is authorized against Ory Permissions,
|
|
20
|
+
audited, and tied to the user → agent identity — built on
|
|
21
|
+
[`ory-argus`](https://pypi.org/project/ory-argus/).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install ory-hermes
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Hermes drives its loop from a `{name: callable}` tool registry (`model_tools`).
|
|
28
|
+
Gate it before handing it to the agent:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from ory_hermes import guard_function_map
|
|
32
|
+
|
|
33
|
+
tools = guard_function_map(model_tools) # returns a gated copy of the registry
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Tool objects (with a `run` / `_run` method) work too:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from ory_hermes import guard_tools
|
|
40
|
+
|
|
41
|
+
tools = guard_tools([search_tool, shell_tool])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In **enforce** mode a denied tool raises `OryDenialError`
|
|
45
|
+
before the tool body runs; Hermes surfaces the denial and the tool never executes. In
|
|
46
|
+
**observe** mode (default) the tool runs and a `permission.observe_deny` activity event is recorded.
|
|
47
|
+
Network / rate-limit errors fail open.
|
|
48
|
+
|
|
49
|
+
> The integration is SDK-free: it wraps Hermes' plain Python registry by duck typing.
|
|
50
|
+
> Hermes is installed through its own bootstrap process and does not publish an
|
|
51
|
+
> installable Python SDK, so this package intentionally has no Hermes dependency or
|
|
52
|
+
> external real-SDK compatibility test.
|
|
53
|
+
|
|
54
|
+
Credentials come from the shared `~/.config/ory-agent-plugins/config.json`, so a login
|
|
55
|
+
performed by any other Ory harness plugin or SDK integration is reused here.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# ory-hermes
|
|
2
|
+
|
|
3
|
+
Ory Agent Security for the [Hermes agent framework](https://github.com/NousResearch/hermes-agent).
|
|
4
|
+
|
|
5
|
+
Wraps your Hermes tool registry so every call is authorized against Ory Permissions,
|
|
6
|
+
audited, and tied to the user → agent identity — built on
|
|
7
|
+
[`ory-argus`](https://pypi.org/project/ory-argus/).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ory-hermes
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Hermes drives its loop from a `{name: callable}` tool registry (`model_tools`).
|
|
14
|
+
Gate it before handing it to the agent:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from ory_hermes import guard_function_map
|
|
18
|
+
|
|
19
|
+
tools = guard_function_map(model_tools) # returns a gated copy of the registry
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Tool objects (with a `run` / `_run` method) work too:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from ory_hermes import guard_tools
|
|
26
|
+
|
|
27
|
+
tools = guard_tools([search_tool, shell_tool])
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
In **enforce** mode a denied tool raises `OryDenialError`
|
|
31
|
+
before the tool body runs; Hermes surfaces the denial and the tool never executes. In
|
|
32
|
+
**observe** mode (default) the tool runs and a `permission.observe_deny` activity event is recorded.
|
|
33
|
+
Network / rate-limit errors fail open.
|
|
34
|
+
|
|
35
|
+
> The integration is SDK-free: it wraps Hermes' plain Python registry by duck typing.
|
|
36
|
+
> Hermes is installed through its own bootstrap process and does not publish an
|
|
37
|
+
> installable Python SDK, so this package intentionally has no Hermes dependency or
|
|
38
|
+
> external real-SDK compatibility test.
|
|
39
|
+
|
|
40
|
+
Credentials come from the shared `~/.config/ory-agent-plugins/config.json`, so a login
|
|
41
|
+
performed by any other Ory harness plugin or SDK integration is reused here.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ory-hermes"
|
|
7
|
+
version = "1.3.0"
|
|
8
|
+
description = "Ory Agent Security for the Hermes agent framework — per-tool authorization, activity logging, and identity propagation by wrapping the tool registry. 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", "hermes", "hermes-agent", "nous", "authorization", "agent", "ai", "permissions"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ory-argus>=1,<2",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/ory_hermes"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Ory Agent Security for the Hermes agent framework.
|
|
2
|
+
|
|
3
|
+
from ory_hermes import guard_function_map
|
|
4
|
+
|
|
5
|
+
tools = guard_function_map(model_tools) # {name: callable} registry
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .tools import (
|
|
11
|
+
guard_callable,
|
|
12
|
+
guard_function_map,
|
|
13
|
+
guard_tools,
|
|
14
|
+
guarded_acall,
|
|
15
|
+
guarded_call,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "1.3.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"guard_function_map",
|
|
22
|
+
"guard_tools",
|
|
23
|
+
"guard_callable",
|
|
24
|
+
"guarded_call",
|
|
25
|
+
"guarded_acall",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Ory Agent Security for the Hermes agent framework.
|
|
2
|
+
|
|
3
|
+
Hermes (``NousResearch/hermes-agent``) drives its conversation loop by resolving a
|
|
4
|
+
tool **registry** — a ``{name: callable}`` map built in ``model_tools.py`` — and
|
|
5
|
+
executing entries via ``handle_function_call``. Its approval hooks
|
|
6
|
+
(``pre_approval_request`` / ``post_approval_response``) and ``_smart_approve`` are
|
|
7
|
+
observation/approval-lifecycle points, not a reliable programmatic veto, so the
|
|
8
|
+
integration gates at the **tool-execution** boundary instead: it wraps each
|
|
9
|
+
callable in the registry so every call is authorized against Ory Permissions,
|
|
10
|
+
logged, and (on first call) preceded by the Ory session gates.
|
|
11
|
+
|
|
12
|
+
- allow / observe / fail-open / interactive → run the tool and record ``tool.complete``.
|
|
13
|
+
- deny (enforce) → raise ``OryDenialError`` so Hermes surfaces the denial and the
|
|
14
|
+
tool never runs.
|
|
15
|
+
|
|
16
|
+
Gate logic lives in ``ory_argus``; no Hermes import is required (the registry is
|
|
17
|
+
duck-typed), so this package installs SDK-free.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ory_argus import (
|
|
28
|
+
GateResult,
|
|
29
|
+
OryAgentClient,
|
|
30
|
+
complete,
|
|
31
|
+
create_session_starter,
|
|
32
|
+
fail,
|
|
33
|
+
gate,
|
|
34
|
+
)
|
|
35
|
+
from ory_argus.adapters import denial_error
|
|
36
|
+
|
|
37
|
+
HARNESS = "hermes"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _gate_or_deny(
|
|
41
|
+
client: OryAgentClient,
|
|
42
|
+
*,
|
|
43
|
+
tool_name: str,
|
|
44
|
+
args: tuple,
|
|
45
|
+
kwargs: dict,
|
|
46
|
+
can_block: bool,
|
|
47
|
+
ensure_session_started: Callable[[], None],
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Run session-start (once) + the permission gate; raise OryDenialError on enforce-deny."""
|
|
50
|
+
ensure_session_started()
|
|
51
|
+
|
|
52
|
+
result: GateResult = gate(
|
|
53
|
+
client,
|
|
54
|
+
harness=HARNESS,
|
|
55
|
+
tool_name=tool_name,
|
|
56
|
+
tool_args={"args": args, "kwargs": kwargs},
|
|
57
|
+
can_block=can_block,
|
|
58
|
+
)
|
|
59
|
+
if result.blocked:
|
|
60
|
+
raise denial_error(result, tool_name)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def guarded_call(
|
|
64
|
+
client: OryAgentClient,
|
|
65
|
+
*,
|
|
66
|
+
tool_name: str,
|
|
67
|
+
fn: Callable,
|
|
68
|
+
args: tuple,
|
|
69
|
+
kwargs: dict,
|
|
70
|
+
can_block: bool = True,
|
|
71
|
+
project_url: str | None = None,
|
|
72
|
+
_session_starter: Callable[[], None] | None = None,
|
|
73
|
+
) -> Any:
|
|
74
|
+
"""SDK-free gate-then-run for a single synchronous tool call."""
|
|
75
|
+
ensure_session_started = _session_starter or create_session_starter(
|
|
76
|
+
client, harness=HARNESS, project_url=project_url
|
|
77
|
+
)
|
|
78
|
+
_gate_or_deny(
|
|
79
|
+
client, tool_name=tool_name, args=args, kwargs=kwargs,
|
|
80
|
+
can_block=can_block, ensure_session_started=ensure_session_started,
|
|
81
|
+
)
|
|
82
|
+
tool_input = {"args": args, "kwargs": kwargs}
|
|
83
|
+
try:
|
|
84
|
+
output = fn(*args, **kwargs)
|
|
85
|
+
complete(client, tool_name=tool_name, input=tool_input, output=output)
|
|
86
|
+
return output
|
|
87
|
+
except Exception as error:
|
|
88
|
+
fail(client, tool_name=tool_name, input=tool_input, error=error)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def guarded_acall(
|
|
93
|
+
client: OryAgentClient,
|
|
94
|
+
*,
|
|
95
|
+
tool_name: str,
|
|
96
|
+
fn: Callable,
|
|
97
|
+
args: tuple,
|
|
98
|
+
kwargs: dict,
|
|
99
|
+
can_block: bool = True,
|
|
100
|
+
project_url: str | None = None,
|
|
101
|
+
_session_starter: Callable[[], None] | None = None,
|
|
102
|
+
) -> Any:
|
|
103
|
+
"""SDK-free gate-then-run for a single async tool call.
|
|
104
|
+
|
|
105
|
+
Same semantics as :func:`guarded_call` — the gate runs (and an enforce-deny raises
|
|
106
|
+
``OryDenialError``) BEFORE the tool body executes; observe/fail-open proceed with activity logging.
|
|
107
|
+
"""
|
|
108
|
+
ensure_session_started = _session_starter or create_session_starter(
|
|
109
|
+
client, harness=HARNESS, project_url=project_url
|
|
110
|
+
)
|
|
111
|
+
_gate_or_deny(
|
|
112
|
+
client, tool_name=tool_name, args=args, kwargs=kwargs,
|
|
113
|
+
can_block=can_block, ensure_session_started=ensure_session_started,
|
|
114
|
+
)
|
|
115
|
+
tool_input = {"args": args, "kwargs": kwargs}
|
|
116
|
+
try:
|
|
117
|
+
output = fn(*args, **kwargs)
|
|
118
|
+
if inspect.isawaitable(output):
|
|
119
|
+
output = await output
|
|
120
|
+
complete(client, tool_name=tool_name, input=tool_input, output=output)
|
|
121
|
+
return output
|
|
122
|
+
except Exception as error:
|
|
123
|
+
fail(client, tool_name=tool_name, input=tool_input, error=error)
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def guard_callable(
|
|
128
|
+
fn: Callable,
|
|
129
|
+
*,
|
|
130
|
+
name: str | None = None,
|
|
131
|
+
client: OryAgentClient,
|
|
132
|
+
can_block: bool = True,
|
|
133
|
+
project_url: str | None = None,
|
|
134
|
+
_session_starter: Callable[[], None] | None = None,
|
|
135
|
+
) -> Callable:
|
|
136
|
+
"""Return a gated wrapper around a single tool callable (sync or async preserved)."""
|
|
137
|
+
tool_name = name or getattr(fn, "__name__", None) or "unknown"
|
|
138
|
+
ensure_session_started = _session_starter or create_session_starter(
|
|
139
|
+
client, harness=HARNESS, project_url=project_url
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if inspect.iscoroutinefunction(fn):
|
|
143
|
+
async def _awrapped(*args, **kwargs):
|
|
144
|
+
return await guarded_acall(
|
|
145
|
+
client, tool_name=tool_name, fn=fn, args=args, kwargs=kwargs,
|
|
146
|
+
can_block=can_block, project_url=project_url,
|
|
147
|
+
_session_starter=ensure_session_started,
|
|
148
|
+
)
|
|
149
|
+
return _awrapped
|
|
150
|
+
|
|
151
|
+
def _wrapped(*args, **kwargs):
|
|
152
|
+
return guarded_call(
|
|
153
|
+
client, tool_name=tool_name, fn=fn, args=args, kwargs=kwargs,
|
|
154
|
+
can_block=can_block, project_url=project_url,
|
|
155
|
+
_session_starter=ensure_session_started,
|
|
156
|
+
)
|
|
157
|
+
return _wrapped
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def guard_function_map(
|
|
161
|
+
function_map: dict[str, Callable],
|
|
162
|
+
*,
|
|
163
|
+
client: OryAgentClient | None = None,
|
|
164
|
+
can_block: bool = True,
|
|
165
|
+
project_url: str | None = None,
|
|
166
|
+
) -> dict[str, Callable]:
|
|
167
|
+
"""Gate every callable in a Hermes tool registry (``{name: callable}``).
|
|
168
|
+
|
|
169
|
+
Returns a NEW dict with each callable wrapped; the session gates run once and are
|
|
170
|
+
shared across every tool in the map. This is the primary Hermes seam — pass the
|
|
171
|
+
``model_tools`` registry through it before handing it to the agent.
|
|
172
|
+
"""
|
|
173
|
+
c = client or OryAgentClient.from_env(HARNESS)
|
|
174
|
+
ensure_session_started = create_session_starter(
|
|
175
|
+
c, harness=HARNESS, project_url=project_url
|
|
176
|
+
)
|
|
177
|
+
return {
|
|
178
|
+
name: guard_callable(
|
|
179
|
+
fn, name=name, client=c, can_block=can_block,
|
|
180
|
+
project_url=project_url, _session_starter=ensure_session_started,
|
|
181
|
+
)
|
|
182
|
+
for name, fn in function_map.items()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _warn_wrap_failed(client: OryAgentClient, event: str, tool_name: str, err: Exception) -> None:
|
|
187
|
+
"""Wrapping failed: the tool will run UNGATED. Log + always warn on stderr (never stdout)."""
|
|
188
|
+
client.logger.warn(event, {"tool": tool_name, "message": str(err)})
|
|
189
|
+
print(
|
|
190
|
+
f"[ory] WARNING: failed to wrap tool '{tool_name}' ({err}); "
|
|
191
|
+
"it will run UNGATED by Ory permission checks.",
|
|
192
|
+
file=sys.stderr,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Method names Hermes-style tool objects execute through, in priority order.
|
|
197
|
+
_SYNC_METHODS = ("run", "_run", "execute", "__call__")
|
|
198
|
+
_ASYNC_METHODS = ("arun", "_arun", "aexecute")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def guard_tools(
|
|
202
|
+
tools: list,
|
|
203
|
+
*,
|
|
204
|
+
client: OryAgentClient | None = None,
|
|
205
|
+
can_block: bool = True,
|
|
206
|
+
project_url: str | None = None,
|
|
207
|
+
) -> list:
|
|
208
|
+
"""Gate a list of Hermes tool objects (or bare callables).
|
|
209
|
+
|
|
210
|
+
For each item: an object exposing a ``run`` / ``_run`` (and optional async
|
|
211
|
+
``arun`` / ``_arun``) method is monkeypatched in place; a bare callable is
|
|
212
|
+
replaced with a gated wrapper in the returned list. The session gates run once,
|
|
213
|
+
shared across every tool. Returns the (possibly re-populated) list.
|
|
214
|
+
"""
|
|
215
|
+
c = client or OryAgentClient.from_env(HARNESS)
|
|
216
|
+
ensure_session_started = create_session_starter(
|
|
217
|
+
c, harness=HARNESS, project_url=project_url
|
|
218
|
+
)
|
|
219
|
+
out: list = []
|
|
220
|
+
for tool in tools:
|
|
221
|
+
# Bare callable (plain function tool): can't patch in place — wrap it.
|
|
222
|
+
if not _has_method(tool, _SYNC_METHODS + _ASYNC_METHODS) and callable(tool):
|
|
223
|
+
out.append(
|
|
224
|
+
guard_callable(
|
|
225
|
+
tool, client=c, can_block=can_block,
|
|
226
|
+
project_url=project_url, _session_starter=ensure_session_started,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
tool_name = getattr(tool, "name", None) or type(tool).__name__
|
|
232
|
+
_patch_methods(
|
|
233
|
+
c, tool, tool_name, _SYNC_METHODS, ensure_session_started,
|
|
234
|
+
can_block, project_url, is_async=False,
|
|
235
|
+
)
|
|
236
|
+
_patch_methods(
|
|
237
|
+
c, tool, tool_name, _ASYNC_METHODS, ensure_session_started,
|
|
238
|
+
can_block, project_url, is_async=True,
|
|
239
|
+
)
|
|
240
|
+
out.append(tool)
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _has_method(tool: Any, names: tuple) -> bool:
|
|
245
|
+
return any(callable(getattr(tool, n, None)) for n in names if n != "__call__")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _patch_methods(
|
|
249
|
+
client: OryAgentClient,
|
|
250
|
+
tool: Any,
|
|
251
|
+
tool_name: str,
|
|
252
|
+
names: tuple,
|
|
253
|
+
ensure_session_started: Callable[[], None],
|
|
254
|
+
can_block: bool,
|
|
255
|
+
project_url: str | None,
|
|
256
|
+
*,
|
|
257
|
+
is_async: bool,
|
|
258
|
+
) -> None:
|
|
259
|
+
for method_name in names:
|
|
260
|
+
if method_name == "__call__":
|
|
261
|
+
continue
|
|
262
|
+
original = getattr(tool, method_name, None)
|
|
263
|
+
if not callable(original):
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
if is_async:
|
|
267
|
+
def _make(run):
|
|
268
|
+
async def _awrapped(*args, **kwargs):
|
|
269
|
+
return await guarded_acall(
|
|
270
|
+
client, tool_name=tool_name, fn=run, args=args, kwargs=kwargs,
|
|
271
|
+
can_block=can_block, project_url=project_url,
|
|
272
|
+
_session_starter=ensure_session_started,
|
|
273
|
+
)
|
|
274
|
+
return _awrapped
|
|
275
|
+
else:
|
|
276
|
+
def _make(run):
|
|
277
|
+
def _wrapped(*args, **kwargs):
|
|
278
|
+
return guarded_call(
|
|
279
|
+
client, tool_name=tool_name, fn=run, args=args, kwargs=kwargs,
|
|
280
|
+
can_block=can_block, project_url=project_url,
|
|
281
|
+
_session_starter=ensure_session_started,
|
|
282
|
+
)
|
|
283
|
+
return _wrapped
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
setattr(tool, method_name, _make(original))
|
|
287
|
+
except Exception as err: # noqa: BLE001 — some tool models are frozen
|
|
288
|
+
_warn_wrap_failed(client, "hermes.wrap_tool.failed", tool_name, err)
|
|
289
|
+
# Only wrap the first execution method that exists (highest priority).
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
__all__ = [
|
|
294
|
+
"HARNESS",
|
|
295
|
+
"guard_callable",
|
|
296
|
+
"guard_function_map",
|
|
297
|
+
"guard_tools",
|
|
298
|
+
"guarded_acall",
|
|
299
|
+
"guarded_call",
|
|
300
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Hermetic tests for the Hermes tool-wrap gate (SDK-free).
|
|
2
|
+
|
|
3
|
+
Covers the native veto shape (``OryDenialError`` raised from the wrapped tool) and
|
|
4
|
+
request-shape delegation (registry name + verbatim args) for both the function-map
|
|
5
|
+
seam and tool objects, plus the shared session sequence. The full permission
|
|
6
|
+
decision matrix is core-owned (``ory-argus/tests/test_adapters.py``) and re-run here
|
|
7
|
+
through ``run_gate_matrix``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from ory_argus.client import PrincipalIdentity
|
|
17
|
+
from ory_argus.denial import OryDenialError
|
|
18
|
+
from ory_argus.testing import (
|
|
19
|
+
GateCase,
|
|
20
|
+
connect_security,
|
|
21
|
+
create_mock_client,
|
|
22
|
+
get_activity_events,
|
|
23
|
+
run_gate_matrix,
|
|
24
|
+
stub_permission_allowed,
|
|
25
|
+
stub_permission_denied,
|
|
26
|
+
)
|
|
27
|
+
from ory_hermes import guard_function_map, guard_tools
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(autouse=True)
|
|
31
|
+
def _isolate(monkeypatch, tmp_path):
|
|
32
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
33
|
+
connect_security(monkeypatch)
|
|
34
|
+
monkeypatch.delenv("ORY_PERMISSION_MODE", raising=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def client_with_user():
|
|
38
|
+
c = create_mock_client(harness="hermes")
|
|
39
|
+
c.set_user_principal(PrincipalIdentity(subject="user:alice"))
|
|
40
|
+
return c
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── function-map seam ──
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_function_map_allow_runs_and_completes():
|
|
47
|
+
c = client_with_user()
|
|
48
|
+
stub_permission_allowed(c)
|
|
49
|
+
ran = []
|
|
50
|
+
|
|
51
|
+
def search(*args, **kwargs):
|
|
52
|
+
ran.append((args, kwargs))
|
|
53
|
+
return "hit"
|
|
54
|
+
|
|
55
|
+
tools = guard_function_map({"search": search}, client=c)
|
|
56
|
+
assert tools["search"]("q", k=1) == "hit"
|
|
57
|
+
assert ran == [(("q",), {"k": 1})]
|
|
58
|
+
invoke = get_activity_events(c, "tool.invoke")[0]
|
|
59
|
+
assert invoke.attributes["allowed"] is True
|
|
60
|
+
assert invoke.attributes["toolName"] == "search"
|
|
61
|
+
assert get_activity_events(c, "tool.complete")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_function_map_enforce_raises_and_skips(monkeypatch):
|
|
65
|
+
monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
|
|
66
|
+
c = client_with_user()
|
|
67
|
+
stub_permission_denied(c)
|
|
68
|
+
ran = []
|
|
69
|
+
|
|
70
|
+
def rm(*args, **kwargs):
|
|
71
|
+
ran.append(True)
|
|
72
|
+
return "gone"
|
|
73
|
+
|
|
74
|
+
tools = guard_function_map({"rm": rm}, client=c)
|
|
75
|
+
with pytest.raises(OryDenialError):
|
|
76
|
+
tools["rm"]()
|
|
77
|
+
assert ran == [] # underlying tool never invoked
|
|
78
|
+
assert get_activity_events(c, "tool.block")[0].attributes["blocked"] is True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_function_map_async(monkeypatch):
|
|
82
|
+
monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
|
|
83
|
+
c = client_with_user()
|
|
84
|
+
stub_permission_denied(c)
|
|
85
|
+
ran = []
|
|
86
|
+
|
|
87
|
+
async def fetch(*args, **kwargs):
|
|
88
|
+
ran.append(True)
|
|
89
|
+
return "data"
|
|
90
|
+
|
|
91
|
+
tools = guard_function_map({"fetch": fetch}, client=c)
|
|
92
|
+
with pytest.raises(OryDenialError):
|
|
93
|
+
asyncio.run(tools["fetch"]())
|
|
94
|
+
assert ran == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_function_map_observe_runs_tool():
|
|
98
|
+
c = client_with_user()
|
|
99
|
+
stub_permission_denied(c) # observe mode (default) → tool still runs
|
|
100
|
+
tools = guard_function_map({"rm": lambda: "gone"}, client=c)
|
|
101
|
+
assert tools["rm"]() == "gone"
|
|
102
|
+
assert get_activity_events(c, "permission.block_observed")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_session_start_once_across_map():
|
|
106
|
+
c = client_with_user()
|
|
107
|
+
stub_permission_allowed(c)
|
|
108
|
+
tools = guard_function_map({"a": lambda: 1, "b": lambda: 2}, client=c)
|
|
109
|
+
tools["a"]()
|
|
110
|
+
tools["b"]()
|
|
111
|
+
assert len(get_activity_events(c, "tool.invoke")) == 2
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── tool objects seam ──
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class FakeTool:
|
|
118
|
+
def __init__(self, name):
|
|
119
|
+
self.name = name
|
|
120
|
+
self.ran = []
|
|
121
|
+
|
|
122
|
+
def run(self, *args, **kwargs):
|
|
123
|
+
self.ran.append((args, kwargs))
|
|
124
|
+
return f"ran:{self.name}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_tool_object_enforce_raises_and_skips(monkeypatch):
|
|
128
|
+
monkeypatch.setenv("ORY_PERMISSION_MODE", "enforce")
|
|
129
|
+
c = client_with_user()
|
|
130
|
+
stub_permission_denied(c)
|
|
131
|
+
tool = FakeTool("shell")
|
|
132
|
+
guard_tools([tool], client=c)
|
|
133
|
+
with pytest.raises(OryDenialError):
|
|
134
|
+
tool.run("rm -rf /")
|
|
135
|
+
assert tool.ran == []
|
|
136
|
+
assert get_activity_events(c, "tool.block")[0].attributes["blocked"] is True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_tool_object_allow_runs():
|
|
140
|
+
c = client_with_user()
|
|
141
|
+
stub_permission_allowed(c)
|
|
142
|
+
tool = FakeTool("search")
|
|
143
|
+
guard_tools([tool], client=c)
|
|
144
|
+
assert tool.run("q") == "ran:search"
|
|
145
|
+
assert get_activity_events(c, "tool.invoke")[0].attributes["toolName"] == "search"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_bare_callable_in_list_is_wrapped():
|
|
149
|
+
c = client_with_user()
|
|
150
|
+
stub_permission_allowed(c)
|
|
151
|
+
|
|
152
|
+
def lookup(*args, **kwargs):
|
|
153
|
+
return "ok"
|
|
154
|
+
|
|
155
|
+
gated = guard_tools([lookup], client=c)
|
|
156
|
+
assert gated[0]("x") == "ok"
|
|
157
|
+
assert get_activity_events(c, "tool.invoke")[0].attributes["toolName"] == "lookup"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_wrap_failure_warns_on_stderr(capsys):
|
|
161
|
+
c = client_with_user()
|
|
162
|
+
stub_permission_allowed(c)
|
|
163
|
+
|
|
164
|
+
class FrozenTool:
|
|
165
|
+
name = "frozen"
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def run(self):
|
|
169
|
+
return lambda: "x"
|
|
170
|
+
|
|
171
|
+
guard_tools([FrozenTool()], client=c)
|
|
172
|
+
err = capsys.readouterr().err
|
|
173
|
+
assert "UNGATED" in err
|
|
174
|
+
assert "frozen" in err
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── full decision matrix (core-owned, re-run through this integration) ──
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_gate_matrix():
|
|
181
|
+
def make(client):
|
|
182
|
+
ran = {}
|
|
183
|
+
|
|
184
|
+
def target(*args, **kwargs):
|
|
185
|
+
ran["yes"] = True
|
|
186
|
+
return "ran"
|
|
187
|
+
|
|
188
|
+
tools = guard_function_map({"target": target}, client=client)
|
|
189
|
+
return GateCase(
|
|
190
|
+
invoke=lambda: tools["target"](),
|
|
191
|
+
tool_ran=lambda: ran.get("yes", False),
|
|
192
|
+
vetoed=lambda out: isinstance(out, OryDenialError),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
run_gate_matrix(make, harness="hermes")
|