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