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.
@@ -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())
@@ -0,0 +1,9 @@
1
+ name: acp
2
+ version: 0.1.0
3
+ description: ACP governance — audit and veto every tool call via the Agentic Control Plane.
4
+ author: GatewayStack
5
+ homepage: https://agenticcontrolplane.com
6
+ provides:
7
+ hooks:
8
+ - pre_tool_call
9
+ - post_tool_call
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