hermes-acp 0.1.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.
- hermes_acp-0.1.0/LICENSE +21 -0
- hermes_acp-0.1.0/PKG-INFO +95 -0
- hermes_acp-0.1.0/README.md +79 -0
- hermes_acp-0.1.0/pyproject.toml +31 -0
- hermes_acp-0.1.0/src/acp_hermes/__init__.py +146 -0
- hermes_acp-0.1.0/src/acp_hermes/cli.py +187 -0
- hermes_acp-0.1.0/src/acp_hermes/plugin.yaml +9 -0
- hermes_acp-0.1.0/tests/__init__.py +0 -0
- hermes_acp-0.1.0/tests/test_cli.py +158 -0
- hermes_acp-0.1.0/tests/test_hooks.py +171 -0
hermes_acp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GatewayStack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-acp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ACP governance plugin for Hermes Agent — audit and veto every tool call via the Agentic Control Plane.
|
|
5
|
+
Project-URL: Homepage, https://agenticcontrolplane.com
|
|
6
|
+
Project-URL: Documentation, https://agenticcontrolplane.com/integrations/hermes
|
|
7
|
+
Project-URL: Source, https://github.com/agentic-control-plane/hermes-acp-plugin
|
|
8
|
+
Project-URL: Issues, https://github.com/agentic-control-plane/hermes-acp-plugin/issues
|
|
9
|
+
Author: GatewayStack
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# hermes-acp
|
|
18
|
+
|
|
19
|
+
ACP governance plugin for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
|
|
20
|
+
|
|
21
|
+
Routes every tool call through the [Agentic Control Plane](https://agenticcontrolplane.com) so you get:
|
|
22
|
+
|
|
23
|
+
- **Audit** — every tool call (terminal, file, web, browser, custom skills) is logged with tenant + session attribution.
|
|
24
|
+
- **Veto** — server-side policy can deny or require approval on individual tool calls before they execute.
|
|
25
|
+
|
|
26
|
+
Companion to the [Claude Code ACP plugin](https://github.com/davidcrowe/claude-code-acp-plugin). Same backend contract, same dashboard, same policies — just wired into Hermes's Python plugin system instead of Claude Code's shell hooks.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install hermes-acp
|
|
32
|
+
hermes plugins enable acp
|
|
33
|
+
hermes-acp login
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`hermes-acp login` opens the dashboard, exchanges your one-time auth token for a workspace API key, and writes it to `~/.acp/credentials`.
|
|
37
|
+
|
|
38
|
+
## Configure
|
|
39
|
+
|
|
40
|
+
For non-interactive setups (CI, devcontainers), skip the `login` step and provide the key directly:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export ACP_BEARER_TOKEN="gsk_yourslug_..."
|
|
44
|
+
# or
|
|
45
|
+
mkdir -p ~/.acp && echo "gsk_yourslug_..." > ~/.acp/credentials
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The env var wins over the file.
|
|
49
|
+
|
|
50
|
+
## CLI
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
hermes-acp login # browser-based authentication + workspace provisioning
|
|
54
|
+
hermes-acp status # check creds + gateway reachability
|
|
55
|
+
hermes-acp logout # remove ~/.acp/credentials
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Optional — point at a non-default backend:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
export ACP_API_BASE="https://api.agenticcontrolplane.com" # default
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
The plugin registers two Hermes hooks:
|
|
67
|
+
|
|
68
|
+
| Hook | Behavior |
|
|
69
|
+
|-----------------|---------------------------------------------------------------|
|
|
70
|
+
| `pre_tool_call` | POSTs to `/govern/tool-use`. Server returns `allow` / `deny` / `ask`. `deny` and `ask` block the tool call with a system message; `allow` passes through. |
|
|
71
|
+
| `post_tool_call`| POSTs to `/govern/tool-output` for observation. Cannot block (Hermes limitation), but server-side audit, redaction logging, and DLP scanning all apply. |
|
|
72
|
+
|
|
73
|
+
### Fail-open
|
|
74
|
+
|
|
75
|
+
Network errors, timeouts (>4s), or malformed responses **fail open** — the tool call proceeds and a warning is written to stderr. ACP outages should never block your work. Server-side per-tenant `failMode: closed` can flip this in a future release.
|
|
76
|
+
|
|
77
|
+
### "Ask" semantic
|
|
78
|
+
|
|
79
|
+
Hermes doesn't support inline approval prompts the way Claude Code does, so an ACP `ask` decision is rendered as a `block` with a message instructing the user to approve in the ACP dashboard and retry. If you want a richer approval UX in Hermes, this is the spot to extend.
|
|
80
|
+
|
|
81
|
+
### Client identity
|
|
82
|
+
|
|
83
|
+
Sends `X-GS-Client: hermes-plugin/<version>` so the dashboard, policy router, and audit log can distinguish Hermes traffic from Claude Code / Cursor / Codex / etc.
|
|
84
|
+
|
|
85
|
+
## Troubleshooting
|
|
86
|
+
|
|
87
|
+
**No audit events appearing.** Check that `ACP_BEARER_TOKEN` is set in the shell that launched `hermes`, not just your `.zshrc` after the fact. Hermes inherits the env at process start.
|
|
88
|
+
|
|
89
|
+
**Every tool call blocked.** Look at stderr. A `[ACP] gateway unreachable` warning means network failure (fail-open kicked in but something else blocked you — maybe a policy from another hook). A `[ACP] Denied by policy: …` means the server returned `deny`; check the policy in the ACP dashboard.
|
|
90
|
+
|
|
91
|
+
**Hooks not firing at all.** Confirm the plugin is enabled: `hermes plugins list`. If `acp` isn't in the enabled list, run `hermes plugins enable acp`.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# hermes-acp
|
|
2
|
+
|
|
3
|
+
ACP governance plugin for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
|
|
4
|
+
|
|
5
|
+
Routes every tool call through the [Agentic Control Plane](https://agenticcontrolplane.com) so you get:
|
|
6
|
+
|
|
7
|
+
- **Audit** — every tool call (terminal, file, web, browser, custom skills) is logged with tenant + session attribution.
|
|
8
|
+
- **Veto** — server-side policy can deny or require approval on individual tool calls before they execute.
|
|
9
|
+
|
|
10
|
+
Companion to the [Claude Code ACP plugin](https://github.com/davidcrowe/claude-code-acp-plugin). Same backend contract, same dashboard, same policies — just wired into Hermes's Python plugin system instead of Claude Code's shell hooks.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install hermes-acp
|
|
16
|
+
hermes plugins enable acp
|
|
17
|
+
hermes-acp login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`hermes-acp login` opens the dashboard, exchanges your one-time auth token for a workspace API key, and writes it to `~/.acp/credentials`.
|
|
21
|
+
|
|
22
|
+
## Configure
|
|
23
|
+
|
|
24
|
+
For non-interactive setups (CI, devcontainers), skip the `login` step and provide the key directly:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export ACP_BEARER_TOKEN="gsk_yourslug_..."
|
|
28
|
+
# or
|
|
29
|
+
mkdir -p ~/.acp && echo "gsk_yourslug_..." > ~/.acp/credentials
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The env var wins over the file.
|
|
33
|
+
|
|
34
|
+
## CLI
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
hermes-acp login # browser-based authentication + workspace provisioning
|
|
38
|
+
hermes-acp status # check creds + gateway reachability
|
|
39
|
+
hermes-acp logout # remove ~/.acp/credentials
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Optional — point at a non-default backend:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export ACP_API_BASE="https://api.agenticcontrolplane.com" # default
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## How it works
|
|
49
|
+
|
|
50
|
+
The plugin registers two Hermes hooks:
|
|
51
|
+
|
|
52
|
+
| Hook | Behavior |
|
|
53
|
+
|-----------------|---------------------------------------------------------------|
|
|
54
|
+
| `pre_tool_call` | POSTs to `/govern/tool-use`. Server returns `allow` / `deny` / `ask`. `deny` and `ask` block the tool call with a system message; `allow` passes through. |
|
|
55
|
+
| `post_tool_call`| POSTs to `/govern/tool-output` for observation. Cannot block (Hermes limitation), but server-side audit, redaction logging, and DLP scanning all apply. |
|
|
56
|
+
|
|
57
|
+
### Fail-open
|
|
58
|
+
|
|
59
|
+
Network errors, timeouts (>4s), or malformed responses **fail open** — the tool call proceeds and a warning is written to stderr. ACP outages should never block your work. Server-side per-tenant `failMode: closed` can flip this in a future release.
|
|
60
|
+
|
|
61
|
+
### "Ask" semantic
|
|
62
|
+
|
|
63
|
+
Hermes doesn't support inline approval prompts the way Claude Code does, so an ACP `ask` decision is rendered as a `block` with a message instructing the user to approve in the ACP dashboard and retry. If you want a richer approval UX in Hermes, this is the spot to extend.
|
|
64
|
+
|
|
65
|
+
### Client identity
|
|
66
|
+
|
|
67
|
+
Sends `X-GS-Client: hermes-plugin/<version>` so the dashboard, policy router, and audit log can distinguish Hermes traffic from Claude Code / Cursor / Codex / etc.
|
|
68
|
+
|
|
69
|
+
## Troubleshooting
|
|
70
|
+
|
|
71
|
+
**No audit events appearing.** Check that `ACP_BEARER_TOKEN` is set in the shell that launched `hermes`, not just your `.zshrc` after the fact. Hermes inherits the env at process start.
|
|
72
|
+
|
|
73
|
+
**Every tool call blocked.** Look at stderr. A `[ACP] gateway unreachable` warning means network failure (fail-open kicked in but something else blocked you — maybe a policy from another hook). A `[ACP] Denied by policy: …` means the server returned `deny`; check the policy in the ACP dashboard.
|
|
74
|
+
|
|
75
|
+
**Hooks not firing at all.** Confirm the plugin is enabled: `hermes plugins list`. If `acp` isn't in the enabled list, run `hermes plugins enable acp`.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hermes-acp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "ACP governance plugin for Hermes Agent — audit and veto every tool call via the Agentic Control Plane."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "GatewayStack" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest>=7.0"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://agenticcontrolplane.com"
|
|
20
|
+
Documentation = "https://agenticcontrolplane.com/integrations/hermes"
|
|
21
|
+
Source = "https://github.com/agentic-control-plane/hermes-acp-plugin"
|
|
22
|
+
Issues = "https://github.com/agentic-control-plane/hermes-acp-plugin/issues"
|
|
23
|
+
|
|
24
|
+
[project.entry-points."hermes_agent.plugins"]
|
|
25
|
+
acp = "acp_hermes"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
hermes-acp = "acp_hermes.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/acp_hermes"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""ACP governance plugin for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Routes every tool call through the Agentic Control Plane for audit and policy
|
|
4
|
+
enforcement. Mirrors the contract used by the Claude Code ACP plugin:
|
|
5
|
+
|
|
6
|
+
- POST /govern/tool-use — pre-call policy check, can deny / ask / allow
|
|
7
|
+
- POST /govern/tool-output — post-call observation, fires-and-forgets
|
|
8
|
+
|
|
9
|
+
Behavior:
|
|
10
|
+
- Fails OPEN on network / parse errors so ACP outages don't block work.
|
|
11
|
+
- Reads bearer token from ACP_BEARER_TOKEN or ~/.acp/credentials.
|
|
12
|
+
- Sends X-GS-Client: hermes-plugin/<version> for per-client policy routing.
|
|
13
|
+
- Hermes post_tool_call is observational (cannot block); we only log there.
|
|
14
|
+
- Hermes has no "ask" semantic, so an ACP `ask` decision is rendered as a
|
|
15
|
+
block with an approval-required message.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
PLUGIN_VERSION = "0.1.0"
|
|
29
|
+
CLIENT_ID = f"hermes-plugin/{PLUGIN_VERSION}"
|
|
30
|
+
|
|
31
|
+
DEFAULT_API_BASE = "https://api.agenticcontrolplane.com"
|
|
32
|
+
REQUEST_TIMEOUT_SECONDS = 4.0
|
|
33
|
+
POST_HOOK_PAYLOAD_CEILING = 200 * 1024 # 200 KB, matches backend scan ceiling.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _api_base() -> str:
|
|
37
|
+
return os.environ.get("ACP_API_BASE", DEFAULT_API_BASE).rstrip("/")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_token() -> str | None:
|
|
41
|
+
token = os.environ.get("ACP_BEARER_TOKEN")
|
|
42
|
+
if token:
|
|
43
|
+
return token.strip()
|
|
44
|
+
try:
|
|
45
|
+
path = Path.home() / ".acp" / "credentials"
|
|
46
|
+
return path.read_text(encoding="utf-8").strip() or None
|
|
47
|
+
except OSError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _post_json(path: str, body: dict[str, Any], token: str) -> dict[str, Any] | None:
|
|
52
|
+
url = f"{_api_base()}{path}"
|
|
53
|
+
data = json.dumps(body).encode("utf-8")
|
|
54
|
+
req = urllib.request.Request(
|
|
55
|
+
url,
|
|
56
|
+
data=data,
|
|
57
|
+
method="POST",
|
|
58
|
+
headers={
|
|
59
|
+
"Authorization": f"Bearer {token}",
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"X-GS-Client": CLIENT_ID,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as resp:
|
|
66
|
+
raw = resp.read()
|
|
67
|
+
if not raw:
|
|
68
|
+
return {}
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(raw)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
return None
|
|
73
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
|
|
74
|
+
sys.stderr.write(f"[ACP] gateway unreachable ({exc}); failing open\n")
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _block(message: str) -> dict[str, str]:
|
|
79
|
+
return {"action": "block", "message": message}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pre_tool_call(
|
|
83
|
+
tool_name: str,
|
|
84
|
+
args: dict[str, Any],
|
|
85
|
+
task_id: str = "",
|
|
86
|
+
**_: Any,
|
|
87
|
+
) -> dict[str, str] | None:
|
|
88
|
+
token = _resolve_token()
|
|
89
|
+
if not token:
|
|
90
|
+
return None # Not configured — pass through.
|
|
91
|
+
|
|
92
|
+
body = {
|
|
93
|
+
"tool_name": tool_name,
|
|
94
|
+
"tool_input": args,
|
|
95
|
+
"session_id": task_id,
|
|
96
|
+
"hook_event_name": "PreToolUse",
|
|
97
|
+
"agent_tier": "interactive",
|
|
98
|
+
}
|
|
99
|
+
result = _post_json("/govern/tool-use", body, token)
|
|
100
|
+
if result is None:
|
|
101
|
+
return None # Fail-open on network / parse error.
|
|
102
|
+
|
|
103
|
+
decision = result.get("decision")
|
|
104
|
+
reason = result.get("reason") or "policy did not return a reason"
|
|
105
|
+
if decision == "deny":
|
|
106
|
+
return _block(f"[ACP] Denied by policy: {reason}")
|
|
107
|
+
if decision == "ask":
|
|
108
|
+
return _block(
|
|
109
|
+
f"[ACP] Approval required: {reason}. "
|
|
110
|
+
"Hermes has no inline approval surface; "
|
|
111
|
+
"approve in the ACP dashboard and retry."
|
|
112
|
+
)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _post_tool_call(
|
|
117
|
+
tool_name: str,
|
|
118
|
+
args: dict[str, Any],
|
|
119
|
+
result: str = "",
|
|
120
|
+
task_id: str = "",
|
|
121
|
+
duration_ms: int = 0,
|
|
122
|
+
**_: Any,
|
|
123
|
+
) -> None:
|
|
124
|
+
token = _resolve_token()
|
|
125
|
+
if not token:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
output_str = result if isinstance(result, str) else json.dumps(result, default=str)
|
|
129
|
+
if len(output_str.encode("utf-8")) > POST_HOOK_PAYLOAD_CEILING:
|
|
130
|
+
output_str = output_str[:POST_HOOK_PAYLOAD_CEILING]
|
|
131
|
+
|
|
132
|
+
body = {
|
|
133
|
+
"tool_name": tool_name,
|
|
134
|
+
"tool_input": args,
|
|
135
|
+
"tool_output": output_str,
|
|
136
|
+
"session_id": task_id,
|
|
137
|
+
"duration_ms": duration_ms,
|
|
138
|
+
"hook_event_name": "PostToolUse",
|
|
139
|
+
"agent_tier": "interactive",
|
|
140
|
+
}
|
|
141
|
+
_post_json("/govern/tool-output", body, token)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def register(ctx: Any) -> None:
|
|
145
|
+
ctx.register_hook("pre_tool_call", _pre_tool_call)
|
|
146
|
+
ctx.register_hook("post_tool_call", _post_tool_call)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""hermes-acp CLI.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
login Open browser, exchange auth token for an API key, write
|
|
5
|
+
~/.acp/credentials. Mirrors the claude-code installer wire
|
|
6
|
+
protocol so the same workspace is reachable from any harness.
|
|
7
|
+
status Print whether ~/.acp/credentials is present and reachable.
|
|
8
|
+
logout Remove ~/.acp/credentials.
|
|
9
|
+
|
|
10
|
+
The login flow:
|
|
11
|
+
1. Open <dashboard>/plugin/authorize in browser.
|
|
12
|
+
2. User pastes the one-time auth token.
|
|
13
|
+
3. POST <api>/plugin/provision with Bearer auth → {apiKey, workspace, isNew}.
|
|
14
|
+
4. Write apiKey to ~/.acp/credentials (chmod 600).
|
|
15
|
+
5. Verify via <api>/govern/health.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
import webbrowser
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from . import PLUGIN_VERSION
|
|
32
|
+
|
|
33
|
+
DEFAULT_API_BASE = "https://api.agenticcontrolplane.com"
|
|
34
|
+
DEFAULT_DASHBOARD = "https://cloud.agenticcontrolplane.com"
|
|
35
|
+
REQUEST_TIMEOUT_SECONDS = 10.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _api_base() -> str:
|
|
39
|
+
return os.environ.get("ACP_API_BASE", DEFAULT_API_BASE).rstrip("/")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _dashboard_base() -> str:
|
|
43
|
+
return os.environ.get("ACP_DASHBOARD_BASE", DEFAULT_DASHBOARD).rstrip("/")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _credentials_path() -> Path:
|
|
47
|
+
return Path.home() / ".acp" / "credentials"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write_credentials(api_key: str) -> Path:
|
|
51
|
+
path = _credentials_path()
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
path.write_text(api_key.strip() + "\n", encoding="utf-8")
|
|
54
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _post_json(url: str, body: dict[str, Any], token: str) -> dict[str, Any]:
|
|
59
|
+
data = json.dumps(body).encode("utf-8")
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
url,
|
|
62
|
+
data=data,
|
|
63
|
+
method="POST",
|
|
64
|
+
headers={
|
|
65
|
+
"Authorization": f"Bearer {token}",
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"X-GS-Client": f"hermes-plugin/{PLUGIN_VERSION}",
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as resp:
|
|
71
|
+
return json.loads(resp.read() or b"{}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get(url: str) -> int:
|
|
75
|
+
req = urllib.request.Request(
|
|
76
|
+
url,
|
|
77
|
+
method="GET",
|
|
78
|
+
headers={"X-GS-Client": f"hermes-plugin/{PLUGIN_VERSION}"},
|
|
79
|
+
)
|
|
80
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as resp:
|
|
81
|
+
return resp.status
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _prompt(message: str) -> str:
|
|
85
|
+
sys.stderr.write(message)
|
|
86
|
+
sys.stderr.flush()
|
|
87
|
+
return input().strip()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
91
|
+
creds = _credentials_path()
|
|
92
|
+
if creds.exists() and not args.force:
|
|
93
|
+
sys.stderr.write(
|
|
94
|
+
f"Credentials already at {creds}. Re-run with --force to reconfigure.\n"
|
|
95
|
+
)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
auth_url = f"{_dashboard_base()}/plugin/authorize"
|
|
99
|
+
sys.stderr.write(f"Opening browser: {auth_url}\n")
|
|
100
|
+
try:
|
|
101
|
+
webbrowser.open(auth_url)
|
|
102
|
+
except webbrowser.Error:
|
|
103
|
+
sys.stderr.write("Could not open browser automatically. Open the URL above.\n")
|
|
104
|
+
|
|
105
|
+
token = _prompt("Paste your token: ")
|
|
106
|
+
if not token:
|
|
107
|
+
sys.stderr.write("No token provided. Aborting.\n")
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
sys.stderr.write("Provisioning workspace...\n")
|
|
111
|
+
try:
|
|
112
|
+
result = _post_json(f"{_api_base()}/plugin/provision", {}, token)
|
|
113
|
+
except urllib.error.HTTPError as exc:
|
|
114
|
+
sys.stderr.write(f"Provision failed: HTTP {exc.code} {exc.reason}\n")
|
|
115
|
+
return 1
|
|
116
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
117
|
+
sys.stderr.write(f"Provision failed: {exc}\n")
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
api_key = result.get("apiKey")
|
|
121
|
+
workspace = result.get("workspace")
|
|
122
|
+
is_new = bool(result.get("isNew"))
|
|
123
|
+
if not api_key or not workspace:
|
|
124
|
+
sys.stderr.write(f"Malformed provision response: {result!r}\n")
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
path = _write_credentials(api_key)
|
|
128
|
+
verb = "Created" if is_new else "Connected to"
|
|
129
|
+
sys.stderr.write(f"{verb} workspace: {workspace}\n")
|
|
130
|
+
sys.stderr.write(f"Credentials written to {path}\n")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
_get(f"{_api_base()}/govern/health")
|
|
134
|
+
sys.stderr.write("Governance endpoint verified.\n")
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
sys.stderr.write(f"Health check failed (non-fatal): {exc}\n")
|
|
137
|
+
|
|
138
|
+
sys.stderr.write(f"\nDashboard: {_dashboard_base()}/logs\n")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_status(_: argparse.Namespace) -> int:
|
|
143
|
+
creds = _credentials_path()
|
|
144
|
+
if not creds.exists():
|
|
145
|
+
sys.stderr.write("Not configured. Run `hermes-acp login`.\n")
|
|
146
|
+
return 1
|
|
147
|
+
sys.stderr.write(f"Credentials present at {creds}\n")
|
|
148
|
+
try:
|
|
149
|
+
status = _get(f"{_api_base()}/govern/health")
|
|
150
|
+
sys.stderr.write(f"Gateway reachable (HTTP {status}).\n")
|
|
151
|
+
return 0
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
sys.stderr.write(f"Gateway unreachable: {exc}\n")
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_logout(_: argparse.Namespace) -> int:
|
|
158
|
+
creds = _credentials_path()
|
|
159
|
+
if not creds.exists():
|
|
160
|
+
sys.stderr.write("Already logged out.\n")
|
|
161
|
+
return 0
|
|
162
|
+
creds.unlink()
|
|
163
|
+
sys.stderr.write(f"Removed {creds}\n")
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(argv: list[str] | None = None) -> int:
|
|
168
|
+
parser = argparse.ArgumentParser(prog="hermes-acp", description="ACP CLI for Hermes Agent")
|
|
169
|
+
parser.add_argument("--version", action="version", version=f"hermes-acp {PLUGIN_VERSION}")
|
|
170
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
171
|
+
|
|
172
|
+
p_login = sub.add_parser("login", help="Authenticate and write ~/.acp/credentials")
|
|
173
|
+
p_login.add_argument("--force", action="store_true", help="Overwrite existing credentials")
|
|
174
|
+
p_login.set_defaults(func=cmd_login)
|
|
175
|
+
|
|
176
|
+
p_status = sub.add_parser("status", help="Show credential and gateway status")
|
|
177
|
+
p_status.set_defaults(func=cmd_status)
|
|
178
|
+
|
|
179
|
+
p_logout = sub.add_parser("logout", help="Remove ~/.acp/credentials")
|
|
180
|
+
p_logout.set_defaults(func=cmd_logout)
|
|
181
|
+
|
|
182
|
+
args = parser.parse_args(argv)
|
|
183
|
+
return int(args.func(args) or 0)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Unit tests for the hermes-acp CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import urllib.error
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from acp_hermes import cli
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeResponse:
|
|
17
|
+
def __init__(self, body: bytes, status: int = 200) -> None:
|
|
18
|
+
self._body = body
|
|
19
|
+
self.status = status
|
|
20
|
+
|
|
21
|
+
def read(self) -> bytes:
|
|
22
|
+
return self._body
|
|
23
|
+
|
|
24
|
+
def __enter__(self) -> "FakeResponse":
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def __exit__(self, *exc: object) -> None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(autouse=True)
|
|
32
|
+
def _home(tmp_path, monkeypatch):
|
|
33
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
34
|
+
monkeypatch.delenv("ACP_API_BASE", raising=False)
|
|
35
|
+
monkeypatch.delenv("ACP_DASHBOARD_BASE", raising=False)
|
|
36
|
+
return tmp_path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _login_args(force: bool = False) -> argparse.Namespace:
|
|
40
|
+
return argparse.Namespace(cmd="login", force=force, func=cli.cmd_login)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_login_success_writes_credentials(tmp_path):
|
|
44
|
+
provision_body = json.dumps(
|
|
45
|
+
{"apiKey": "acp_live_key", "workspace": "ws-1", "isNew": True}
|
|
46
|
+
).encode()
|
|
47
|
+
|
|
48
|
+
def _urlopen(req, timeout): # noqa: ARG001
|
|
49
|
+
if req.full_url.endswith("/plugin/provision"):
|
|
50
|
+
return FakeResponse(provision_body)
|
|
51
|
+
if req.full_url.endswith("/govern/health"):
|
|
52
|
+
return FakeResponse(b"", status=200)
|
|
53
|
+
raise AssertionError(f"unexpected url {req.full_url}")
|
|
54
|
+
|
|
55
|
+
with patch("urllib.request.urlopen", _urlopen), patch(
|
|
56
|
+
"webbrowser.open", return_value=True
|
|
57
|
+
), patch("builtins.input", return_value="paste_token"):
|
|
58
|
+
rc = cli.cmd_login(_login_args())
|
|
59
|
+
assert rc == 0
|
|
60
|
+
creds = tmp_path / ".acp" / "credentials"
|
|
61
|
+
assert creds.exists()
|
|
62
|
+
assert creds.read_text().strip() == "acp_live_key"
|
|
63
|
+
# Mode should be 0600
|
|
64
|
+
assert oct(creds.stat().st_mode)[-3:] == "600"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_login_aborts_when_token_empty(tmp_path):
|
|
68
|
+
with patch("webbrowser.open"), patch("builtins.input", return_value=""):
|
|
69
|
+
rc = cli.cmd_login(_login_args())
|
|
70
|
+
assert rc == 1
|
|
71
|
+
assert not (tmp_path / ".acp" / "credentials").exists()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_login_existing_credentials_short_circuits(tmp_path):
|
|
75
|
+
(tmp_path / ".acp").mkdir()
|
|
76
|
+
(tmp_path / ".acp" / "credentials").write_text("existing")
|
|
77
|
+
rc = cli.cmd_login(_login_args(force=False))
|
|
78
|
+
assert rc == 0
|
|
79
|
+
# Existing token preserved
|
|
80
|
+
assert (tmp_path / ".acp" / "credentials").read_text() == "existing"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_login_force_overwrites(tmp_path):
|
|
84
|
+
(tmp_path / ".acp").mkdir()
|
|
85
|
+
(tmp_path / ".acp" / "credentials").write_text("stale")
|
|
86
|
+
provision_body = json.dumps(
|
|
87
|
+
{"apiKey": "fresh_key", "workspace": "ws", "isNew": False}
|
|
88
|
+
).encode()
|
|
89
|
+
|
|
90
|
+
def _urlopen(req, timeout): # noqa: ARG001
|
|
91
|
+
return FakeResponse(provision_body if "provision" in req.full_url else b"")
|
|
92
|
+
|
|
93
|
+
with patch("urllib.request.urlopen", _urlopen), patch(
|
|
94
|
+
"webbrowser.open"
|
|
95
|
+
), patch("builtins.input", return_value="tok"):
|
|
96
|
+
rc = cli.cmd_login(_login_args(force=True))
|
|
97
|
+
assert rc == 0
|
|
98
|
+
assert (tmp_path / ".acp" / "credentials").read_text().strip() == "fresh_key"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_login_provision_http_error_returns_1(tmp_path):
|
|
102
|
+
def _urlopen(req, timeout): # noqa: ARG001
|
|
103
|
+
raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
|
|
104
|
+
|
|
105
|
+
with patch("urllib.request.urlopen", _urlopen), patch("webbrowser.open"), patch(
|
|
106
|
+
"builtins.input", return_value="bad_tok"
|
|
107
|
+
):
|
|
108
|
+
rc = cli.cmd_login(_login_args())
|
|
109
|
+
assert rc == 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_login_malformed_response_returns_1(tmp_path):
|
|
113
|
+
def _urlopen(req, timeout): # noqa: ARG001
|
|
114
|
+
return FakeResponse(b'{"foo": "bar"}') # missing apiKey/workspace
|
|
115
|
+
|
|
116
|
+
with patch("urllib.request.urlopen", _urlopen), patch("webbrowser.open"), patch(
|
|
117
|
+
"builtins.input", return_value="tok"
|
|
118
|
+
):
|
|
119
|
+
rc = cli.cmd_login(_login_args())
|
|
120
|
+
assert rc == 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_logout_removes_credentials(tmp_path):
|
|
124
|
+
(tmp_path / ".acp").mkdir()
|
|
125
|
+
(tmp_path / ".acp" / "credentials").write_text("k")
|
|
126
|
+
rc = cli.cmd_logout(argparse.Namespace())
|
|
127
|
+
assert rc == 0
|
|
128
|
+
assert not (tmp_path / ".acp" / "credentials").exists()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_logout_when_already_logged_out(tmp_path):
|
|
132
|
+
rc = cli.cmd_logout(argparse.Namespace())
|
|
133
|
+
assert rc == 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_status_missing_credentials(tmp_path):
|
|
137
|
+
rc = cli.cmd_status(argparse.Namespace())
|
|
138
|
+
assert rc == 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_status_credentials_present_and_healthy(tmp_path):
|
|
142
|
+
(tmp_path / ".acp").mkdir()
|
|
143
|
+
(tmp_path / ".acp" / "credentials").write_text("k")
|
|
144
|
+
with patch("urllib.request.urlopen", lambda req, timeout: FakeResponse(b"", 200)):
|
|
145
|
+
rc = cli.cmd_status(argparse.Namespace())
|
|
146
|
+
assert rc == 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_status_credentials_present_gateway_down(tmp_path):
|
|
150
|
+
(tmp_path / ".acp").mkdir()
|
|
151
|
+
(tmp_path / ".acp" / "credentials").write_text("k")
|
|
152
|
+
|
|
153
|
+
def _down(req, timeout): # noqa: ARG001
|
|
154
|
+
raise urllib.error.URLError("down")
|
|
155
|
+
|
|
156
|
+
with patch("urllib.request.urlopen", _down):
|
|
157
|
+
rc = cli.cmd_status(argparse.Namespace())
|
|
158
|
+
assert rc == 2
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Unit tests for pre/post tool-call hooks.
|
|
2
|
+
|
|
3
|
+
Strategy: monkeypatch urllib.request.urlopen with a fake that records the
|
|
4
|
+
request and returns a canned JSON response. No real network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from acp_hermes import _post_tool_call, _pre_tool_call, register
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeResponse:
|
|
20
|
+
def __init__(self, body: bytes, status: int = 200) -> None:
|
|
21
|
+
self._body = body
|
|
22
|
+
self.status = status
|
|
23
|
+
|
|
24
|
+
def read(self) -> bytes:
|
|
25
|
+
return self._body
|
|
26
|
+
|
|
27
|
+
def __enter__(self) -> "FakeResponse":
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def __exit__(self, *exc: object) -> None:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fake_urlopen(payload: dict, recorded: list | None = None):
|
|
35
|
+
body = json.dumps(payload).encode()
|
|
36
|
+
|
|
37
|
+
def _impl(req, timeout): # noqa: ARG001 — match urlopen signature
|
|
38
|
+
if recorded is not None:
|
|
39
|
+
recorded.append(req)
|
|
40
|
+
return FakeResponse(body)
|
|
41
|
+
|
|
42
|
+
return _impl
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture(autouse=True)
|
|
46
|
+
def _isolate_credentials(tmp_path, monkeypatch):
|
|
47
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
48
|
+
monkeypatch.delenv("ACP_BEARER_TOKEN", raising=False)
|
|
49
|
+
monkeypatch.delenv("ACP_API_BASE", raising=False)
|
|
50
|
+
yield
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_token(tmp_path, token: str = "acp_test_token") -> None:
|
|
54
|
+
acp_dir = tmp_path / ".acp"
|
|
55
|
+
acp_dir.mkdir()
|
|
56
|
+
(acp_dir / "credentials").write_text(token)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_pre_no_token_passes_through(tmp_path):
|
|
60
|
+
# No env var, no credentials file: hook must be a no-op.
|
|
61
|
+
assert _pre_tool_call("terminal", {"command": "ls"}, task_id="t1") is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_pre_allow_returns_none(tmp_path):
|
|
65
|
+
_write_token(tmp_path)
|
|
66
|
+
with patch("urllib.request.urlopen", _fake_urlopen({"decision": "allow"})):
|
|
67
|
+
assert _pre_tool_call("terminal", {"command": "ls"}, task_id="t1") is None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_pre_deny_returns_block(tmp_path):
|
|
71
|
+
_write_token(tmp_path)
|
|
72
|
+
with patch(
|
|
73
|
+
"urllib.request.urlopen",
|
|
74
|
+
_fake_urlopen({"decision": "deny", "reason": "destructive command"}),
|
|
75
|
+
):
|
|
76
|
+
result = _pre_tool_call("terminal", {"command": "rm -rf /"}, task_id="t1")
|
|
77
|
+
assert result is not None
|
|
78
|
+
assert result["action"] == "block"
|
|
79
|
+
assert "destructive command" in result["message"]
|
|
80
|
+
assert "[ACP]" in result["message"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_pre_ask_renders_as_block(tmp_path):
|
|
84
|
+
_write_token(tmp_path)
|
|
85
|
+
with patch(
|
|
86
|
+
"urllib.request.urlopen",
|
|
87
|
+
_fake_urlopen({"decision": "ask", "reason": "needs review"}),
|
|
88
|
+
):
|
|
89
|
+
result = _pre_tool_call("terminal", {"command": "deploy"}, task_id="t1")
|
|
90
|
+
assert result is not None
|
|
91
|
+
assert result["action"] == "block"
|
|
92
|
+
assert "Approval required" in result["message"]
|
|
93
|
+
assert "dashboard" in result["message"].lower()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_pre_network_error_fails_open(tmp_path):
|
|
97
|
+
_write_token(tmp_path)
|
|
98
|
+
|
|
99
|
+
def _raise(req, timeout): # noqa: ARG001
|
|
100
|
+
raise urllib.error.URLError("connection refused")
|
|
101
|
+
|
|
102
|
+
with patch("urllib.request.urlopen", _raise):
|
|
103
|
+
assert _pre_tool_call("terminal", {"command": "ls"}, task_id="t1") is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_pre_timeout_fails_open(tmp_path):
|
|
107
|
+
_write_token(tmp_path)
|
|
108
|
+
|
|
109
|
+
def _raise(req, timeout): # noqa: ARG001
|
|
110
|
+
raise TimeoutError("slow")
|
|
111
|
+
|
|
112
|
+
with patch("urllib.request.urlopen", _raise):
|
|
113
|
+
assert _pre_tool_call("terminal", {"command": "ls"}, task_id="t1") is None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_pre_sends_correct_headers_and_body(tmp_path):
|
|
117
|
+
_write_token(tmp_path, "tok_abc")
|
|
118
|
+
recorded: list = []
|
|
119
|
+
with patch("urllib.request.urlopen", _fake_urlopen({"decision": "allow"}, recorded)):
|
|
120
|
+
_pre_tool_call("terminal", {"command": "ls"}, task_id="session-42")
|
|
121
|
+
assert len(recorded) == 1
|
|
122
|
+
req = recorded[0]
|
|
123
|
+
assert req.full_url.endswith("/govern/tool-use")
|
|
124
|
+
assert req.headers["Authorization"] == "Bearer tok_abc"
|
|
125
|
+
assert req.headers["X-gs-client"].startswith("hermes-plugin/")
|
|
126
|
+
body = json.loads(req.data)
|
|
127
|
+
assert body["tool_name"] == "terminal"
|
|
128
|
+
assert body["tool_input"] == {"command": "ls"}
|
|
129
|
+
assert body["session_id"] == "session-42"
|
|
130
|
+
assert body["hook_event_name"] == "PreToolUse"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_post_observational_no_return(tmp_path):
|
|
134
|
+
_write_token(tmp_path)
|
|
135
|
+
with patch("urllib.request.urlopen", _fake_urlopen({"action": "redact"})):
|
|
136
|
+
# post hook returns None even when the server says redact —
|
|
137
|
+
# Hermes can't act on it, so we don't propagate.
|
|
138
|
+
result = _post_tool_call(
|
|
139
|
+
"terminal", {"command": "ls"}, result="output", task_id="t", duration_ms=42
|
|
140
|
+
)
|
|
141
|
+
assert result is None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_post_truncates_large_payload(tmp_path):
|
|
145
|
+
_write_token(tmp_path)
|
|
146
|
+
big = "x" * (300 * 1024) # 300 KB, over the 200 KB ceiling
|
|
147
|
+
recorded: list = []
|
|
148
|
+
with patch("urllib.request.urlopen", _fake_urlopen({}, recorded)):
|
|
149
|
+
_post_tool_call("terminal", {}, result=big, task_id="t", duration_ms=1)
|
|
150
|
+
body = json.loads(recorded[0].data)
|
|
151
|
+
assert len(body["tool_output"]) == 200 * 1024
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_post_no_token_skips_request(tmp_path):
|
|
155
|
+
recorded: list = []
|
|
156
|
+
with patch("urllib.request.urlopen", _fake_urlopen({}, recorded)):
|
|
157
|
+
_post_tool_call("terminal", {}, result="x", task_id="t", duration_ms=1)
|
|
158
|
+
assert recorded == []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_register_wires_both_hooks():
|
|
162
|
+
calls: list = []
|
|
163
|
+
|
|
164
|
+
class FakeCtx:
|
|
165
|
+
def register_hook(self, name, fn):
|
|
166
|
+
calls.append((name, fn))
|
|
167
|
+
|
|
168
|
+
register(FakeCtx())
|
|
169
|
+
names = [c[0] for c in calls]
|
|
170
|
+
assert "pre_tool_call" in names
|
|
171
|
+
assert "post_tool_call" in names
|