capsule-hermes 0.4.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,278 @@
1
+ # Local Docker data
2
+ .docker/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ !apps/portal/src/lib/
22
+ !apps/backend-backoffice/ui/src/lib/
23
+ !libs/ui/src/lib/
24
+ lib64/
25
+ parts/
26
+ sdist/
27
+ var/
28
+ wheels/
29
+ share/python-wheels/
30
+ *.egg-info/
31
+ .installed.cfg
32
+ *.egg
33
+ MANIFEST
34
+
35
+ # PyInstaller
36
+ # Usually these files are written by a python script from a template
37
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
38
+ *.manifest
39
+ *.spec
40
+
41
+ # Installer logs
42
+ pip-log.txt
43
+ pip-delete-this-directory.txt
44
+
45
+ # Unit test / coverage reports
46
+ htmlcov/
47
+ .tox/
48
+ .nox/
49
+ .coverage
50
+ .coverage.*
51
+ .cache
52
+ nosetests.xml
53
+ coverage.xml
54
+ *.cover
55
+ *.py,cover
56
+ .hypothesis/
57
+ .pytest_cache/
58
+ cover/
59
+
60
+ # Translations
61
+ *.mo
62
+ *.pot
63
+
64
+ # Django stuff:
65
+ *.log
66
+ local_settings.py
67
+ db.sqlite3
68
+ db.sqlite3-journal
69
+
70
+ # Flask stuff:
71
+ instance/
72
+ .webassets-cache
73
+
74
+ # Scrapy stuff:
75
+ .scrapy
76
+
77
+ # Sphinx documentation
78
+ docs/_build/
79
+
80
+ # PyBuilder
81
+ .pybuilder/
82
+ target/
83
+
84
+ # Jupyter Notebook
85
+ .ipynb_checkpoints
86
+
87
+ # IPython
88
+ profile_default/
89
+ ipython_config.py
90
+
91
+ # pyenv
92
+ # For a library or package, you might want to ignore these files since the code is
93
+ # intended to run in multiple environments; otherwise, check them in:
94
+ # .python-version
95
+
96
+ # pipenv
97
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
98
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
99
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
100
+ # install all needed dependencies.
101
+ #Pipfile.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ #pdm.lock
113
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
+ # in version control.
115
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
+ .pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
+ __pypackages__/
122
+
123
+ # Celery stuff
124
+ celerybeat-schedule
125
+ celerybeat.pid
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # pytype static type analyzer
158
+ .pytype/
159
+
160
+ # Cython debug symbols
161
+ cython_debug/
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
167
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
+ .idea/
169
+
170
+
171
+
172
+ # dependencies
173
+ /node_modules
174
+ /.pnp
175
+ .pnp.js
176
+
177
+ # testing
178
+ /coverage
179
+
180
+ # production
181
+ /build
182
+
183
+ # misc
184
+ .DS_Store
185
+ .env.local
186
+ .env.development.local
187
+ .env.test.local
188
+ .env.production.local
189
+
190
+ npm-debug.log*
191
+ yarn-debug.log*
192
+ yarn-error.log*
193
+
194
+ .pnp.*
195
+ .yarn/*
196
+ !.yarn/patches
197
+ !.yarn/plugins
198
+ !.yarn/releases
199
+ !.yarn/sdks
200
+ !.yarn/versions
201
+
202
+ .nx/cache
203
+ .nx/workspace-data
204
+ vite.config.*.timestamp*
205
+ vitest.config.*.timestamp*
206
+ test-output
207
+
208
+ # Ignore cursor IDE files, for now..
209
+ .cursor/
210
+
211
+ # TypeScript cache
212
+ *.tsbuildinfo
213
+ out-tsc/
214
+
215
+ # Backoffice UI build output (generated by backoffice-ui:build)
216
+ apps/backend-backoffice/public/
217
+
218
+ .vscode/settings.json
219
+ .vscode/mcp.json
220
+
221
+ .github/instructions/*.instructions.md
222
+ instructions.txt
223
+ node_modules
224
+
225
+ # Vite build output assets
226
+ apps/backend-backoffice/public/assets/
227
+
228
+ # Figma temporary assets
229
+ figma-assets/
230
+ tmp-figma-assets/
231
+ apps/portal/logs
232
+ logs
233
+
234
+ # CDN assets (synced from GCS for local development)
235
+ cdn/
236
+
237
+ # Private assets / files for local development, not meant to be shared
238
+ .local/
239
+
240
+ # Claude Code (machine-specific)
241
+ .claude/teams/*/inboxes/
242
+ .claude/settings.local.json
243
+ CLAUDE.local.md
244
+ .worktrees/
245
+
246
+ # Pinned protoc binary cache
247
+ .protoc/
248
+
249
+
250
+ .claude/scheduled_tasks.lock
251
+ .claude/tmp/
252
+ .claude/worktrees/
253
+ # Test artifacts
254
+ screenshots/
255
+ libs/graph/src/**/__screenshots__/
256
+ !libs/graph/src/components/__tests__/__screenshots__/
257
+ .playwright-mcp/
258
+
259
+ # Designer prototypes / scratch docs (kept local, not tracked)
260
+ /notification-empty-state.html
261
+ /notification-channels-empty-state.html
262
+ /notification-popover-prototype.html
263
+ /capsule-qr.html
264
+ /policy-violations-empty-states.html
265
+ /figma-prompt-notification-states.md
266
+ /discovery-node-edge-audit.md
267
+
268
+ # UI lib test CSS — built fresh by libs/ui `npm run build:test-css` before each vitest run
269
+ libs/ui/playwright/output.css
270
+ libs/graph/storybook-static
271
+
272
+ # Codex
273
+ .agents/
274
+ .codex/
275
+ AGENTS.md
276
+ .claude/worktrees
277
+ .nx/polygraph
278
+ .nx/self-healing
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: capsule-hermes
3
+ Version: 0.4.0
4
+ Summary: Capsule plugin for Hermes — forwards hook events to the Capsule agentsecurity service for centralized policy enforcement and audit.
5
+ Project-URL: Repository, https://github.com/capsulesecurity/capsule
6
+ Project-URL: Documentation, https://docs.capsulesecurity.io/guides/hermes
7
+ Author: Capsule Security
8
+ License: Apache-2.0
9
+ Keywords: agent-security,capsule,hermes,hermes-plugin,policy,security
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.27
15
+ Description-Content-Type: text/markdown
16
+
17
+ # capsule-hermes
18
+
19
+ Capsule plugin for the [Hermes](https://nousresearch.com/hermes) autonomous AI agent runtime. Forwards Hermes hook events to the Capsule `agentsecurity` service for centralized policy enforcement and audit.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install capsule-hermes
25
+ ```
26
+
27
+ ## Configure
28
+
29
+ Register the plugin in your Hermes runtime config (e.g. `hermes.yaml`):
30
+
31
+ ```yaml
32
+ plugins:
33
+ - module: capsule_hermes
34
+ config:
35
+ endpoint: "https://agents.capsulesecurity.io/v1/hermes/hooks/events"
36
+ token: ${CAPSULE_HERMES_TOKEN}
37
+ ```
38
+
39
+ The `endpoint` and `token` values are issued by Capsule when you install the Hermes integration in the Capsule portal (**Integrations → Hermes**).
40
+
41
+ ### Optional config
42
+
43
+ | Key | Default | Description |
44
+ | --------------- | ------- | -------------------------------------------------------------------------------------------------------- |
45
+ | `block_on_risk` | `true` | Apply server `block` verdicts. Set to `false` to run the plugin in observe-only mode. |
46
+ | `fail_open` | `true` | Allow the event through if Capsule is unreachable or returns a non-2xx response. Set to `false` to fail closed. |
47
+ | `timeout_ms` | `5000` | Per-request HTTP timeout in milliseconds. |
48
+
49
+ ## Hooks covered
50
+
51
+ The plugin registers handlers for the Hermes runtime hooks Capsule supports:
52
+
53
+ - **Blocking:** `pre_tool_call`, `pre_llm_call`, `pre_gateway_dispatch`, `pre_approval_request`
54
+ - **Observation:** `post_tool_call`, `post_llm_call`, `post_approval_response`, `subagent_stop`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`
55
+
56
+ Blocking hooks await Capsule's verdict and return `{"action": "block", "message": ...}` to Hermes when the server denies the action. Observation hooks dispatch fire-and-forget so telemetry never stalls the agent.
57
+
58
+ ## License
59
+
60
+ Apache-2.0.
@@ -0,0 +1,44 @@
1
+ # capsule-hermes
2
+
3
+ Capsule plugin for the [Hermes](https://nousresearch.com/hermes) autonomous AI agent runtime. Forwards Hermes hook events to the Capsule `agentsecurity` service for centralized policy enforcement and audit.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install capsule-hermes
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ Register the plugin in your Hermes runtime config (e.g. `hermes.yaml`):
14
+
15
+ ```yaml
16
+ plugins:
17
+ - module: capsule_hermes
18
+ config:
19
+ endpoint: "https://agents.capsulesecurity.io/v1/hermes/hooks/events"
20
+ token: ${CAPSULE_HERMES_TOKEN}
21
+ ```
22
+
23
+ The `endpoint` and `token` values are issued by Capsule when you install the Hermes integration in the Capsule portal (**Integrations → Hermes**).
24
+
25
+ ### Optional config
26
+
27
+ | Key | Default | Description |
28
+ | --------------- | ------- | -------------------------------------------------------------------------------------------------------- |
29
+ | `block_on_risk` | `true` | Apply server `block` verdicts. Set to `false` to run the plugin in observe-only mode. |
30
+ | `fail_open` | `true` | Allow the event through if Capsule is unreachable or returns a non-2xx response. Set to `false` to fail closed. |
31
+ | `timeout_ms` | `5000` | Per-request HTTP timeout in milliseconds. |
32
+
33
+ ## Hooks covered
34
+
35
+ The plugin registers handlers for the Hermes runtime hooks Capsule supports:
36
+
37
+ - **Blocking:** `pre_tool_call`, `pre_llm_call`, `pre_gateway_dispatch`, `pre_approval_request`
38
+ - **Observation:** `post_tool_call`, `post_llm_call`, `post_approval_response`, `subagent_stop`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`
39
+
40
+ Blocking hooks await Capsule's verdict and return `{"action": "block", "message": ...}` to Hermes when the server denies the action. Observation hooks dispatch fire-and-forget so telemetry never stalls the agent.
41
+
42
+ ## License
43
+
44
+ Apache-2.0.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "capsule-hermes"
7
+ version = "0.4.0"
8
+ description = "Capsule plugin for Hermes — forwards hook events to the Capsule agentsecurity service for centralized policy enforcement and audit."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Capsule Security" }]
13
+ keywords = [
14
+ "hermes",
15
+ "hermes-plugin",
16
+ "capsule",
17
+ "security",
18
+ "policy",
19
+ "agent-security",
20
+ ]
21
+ classifiers = [
22
+ "License :: OSI Approved :: Apache Software License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.27",
28
+ ]
29
+
30
+ [project.urls]
31
+ Repository = "https://github.com/capsulesecurity/capsule"
32
+ Documentation = "https://docs.capsulesecurity.io/guides/hermes"
33
+
34
+ [project.entry-points."hermes_agent.plugins"]
35
+ capsule = "capsule_hermes"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/capsule_hermes"]
@@ -0,0 +1,146 @@
1
+ """Capsule plugin for the Hermes autonomous AI agent runtime.
2
+
3
+ Forwards Hermes hook events to the Capsule agentsecurity service for centralized
4
+ policy enforcement and audit.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import threading
12
+ import uuid
13
+ from typing import Any, Mapping
14
+
15
+ from .config import CapsulePluginConfig
16
+ from .normalize import normalize
17
+ from .transport import CapsuleTransport
18
+ from .types import HermesHookEnvelope
19
+
20
+ PLUGIN_ID = "capsule"
21
+ PLUGIN_VERSION = "0.4.0"
22
+
23
+ _BLOCKING_HOOKS = ("pre_tool_call",)
24
+ _OBSERVATION_HOOKS = (
25
+ "pre_llm_call",
26
+ "pre_gateway_dispatch",
27
+ "pre_approval_request",
28
+ "post_tool_call",
29
+ "post_llm_call",
30
+ "post_approval_response",
31
+ "subagent_stop",
32
+ "on_session_start",
33
+ "on_session_end",
34
+ "on_session_finalize",
35
+ "on_session_reset",
36
+ )
37
+
38
+ logger = logging.getLogger("capsule_hermes")
39
+
40
+
41
+ def _load_config_block() -> Mapping[str, Any]:
42
+ """Read the `plugins.entries.capsule` block from Hermes' config.yaml.
43
+
44
+ Returns an empty dict when the block is absent so env vars can still drive
45
+ configuration in CI/dev environments.
46
+ """
47
+ try:
48
+ from hermes_cli.config import cfg_get, load_config # type: ignore
49
+ except Exception:
50
+ return {}
51
+ try:
52
+ cfg = load_config()
53
+ block = cfg_get(cfg, "plugins", "entries", PLUGIN_ID, default={})
54
+ return block if isinstance(block, dict) else {}
55
+ except Exception:
56
+ return {}
57
+
58
+
59
+ def _map_verdict(body: Any, block_on_risk: bool) -> dict | None:
60
+ """Translate a Capsule server response into a Hermes block directive."""
61
+ if not isinstance(body, Mapping):
62
+ return None
63
+ if body.get("block") is True and block_on_risk:
64
+ return {
65
+ "action": "block",
66
+ "message": body.get("blockReason") or "Blocked by Capsule policy",
67
+ }
68
+ return None
69
+
70
+
71
+ def register(ctx: Any) -> None:
72
+ """Plugin entry point — called by Hermes during discovery."""
73
+ try:
74
+ cfg = CapsulePluginConfig.load(_load_config_block())
75
+ except ValueError as exc:
76
+ logger.warning("capsule_hermes: not registering hooks — %s", exc)
77
+ return
78
+
79
+ transport = CapsuleTransport(cfg)
80
+ user = cfg.user or os.environ.get("USER") or "unknown"
81
+
82
+ def dispatch(hook_event_name: str, payload: Mapping[str, Any]):
83
+ correlation_id = str(uuid.uuid4())
84
+ envelope = HermesHookEnvelope(
85
+ hook=normalize(hook_event_name, payload),
86
+ user=user,
87
+ plugin_version=PLUGIN_VERSION,
88
+ )
89
+ try:
90
+ return transport.post(envelope, correlation_id), correlation_id
91
+ except Exception as exc:
92
+ logger.error(
93
+ "capsule_hermes: request failed: %s (correlation_id=%s)",
94
+ exc,
95
+ correlation_id,
96
+ )
97
+ return None, correlation_id
98
+
99
+ def make_blocking_handler(name: str):
100
+ def handler(**kwargs: Any):
101
+ response, correlation_id = dispatch(name, kwargs)
102
+ if response is None:
103
+ return None if cfg.fail_open else {
104
+ "action": "block",
105
+ "message": "Capsule policy unavailable",
106
+ }
107
+ if not response.ok:
108
+ logger.warning(
109
+ "capsule_hermes: server returned %s (correlation_id=%s)",
110
+ response.status,
111
+ correlation_id,
112
+ )
113
+ return None if cfg.fail_open else {
114
+ "action": "block",
115
+ "message": "Capsule policy unavailable",
116
+ }
117
+ return _map_verdict(response.body, cfg.block_on_risk)
118
+
119
+ handler.__name__ = f"capsule_{name}"
120
+ return handler
121
+
122
+ def make_observation_handler(name: str):
123
+ def handler(**kwargs: Any):
124
+ t = threading.Thread(
125
+ target=dispatch, args=(name, kwargs), daemon=True, name=f"capsule-{name}"
126
+ )
127
+ t.start()
128
+ return None
129
+
130
+ handler.__name__ = f"capsule_{name}"
131
+ return handler
132
+
133
+ for name in _BLOCKING_HOOKS:
134
+ ctx.register_hook(name, make_blocking_handler(name))
135
+ for name in _OBSERVATION_HOOKS:
136
+ ctx.register_hook(name, make_observation_handler(name))
137
+
138
+ logger.info(
139
+ "capsule_hermes: registered (endpoint=%s, block_on_risk=%s, fail_open=%s)",
140
+ cfg.endpoint,
141
+ cfg.block_on_risk,
142
+ cfg.fail_open,
143
+ )
144
+
145
+
146
+ __all__ = ["register", "CapsulePluginConfig", "PLUGIN_ID", "PLUGIN_VERSION"]
@@ -0,0 +1,69 @@
1
+ """Plugin configuration parsing for the Capsule Hermes plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Mapping
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CapsulePluginConfig:
12
+ """Validated Capsule plugin configuration.
13
+
14
+ Source precedence (highest first): env vars `CAPSULE_HERMES_*`, then the
15
+ `plugins.entries.capsule.*` block in Hermes' config.yaml.
16
+ """
17
+
18
+ endpoint: str
19
+ token: str
20
+ block_on_risk: bool = True
21
+ fail_open: bool = True
22
+ timeout_ms: int = 5000
23
+ user: str | None = None
24
+
25
+ @classmethod
26
+ def load(cls, raw: Mapping[str, Any] | None = None) -> "CapsulePluginConfig":
27
+ raw = raw or {}
28
+ endpoint = os.environ.get("CAPSULE_HERMES_ENDPOINT") or raw.get("endpoint")
29
+ token = os.environ.get("CAPSULE_HERMES_TOKEN") or raw.get("token")
30
+ if not isinstance(endpoint, str) or not endpoint:
31
+ raise ValueError(
32
+ "capsule_hermes: endpoint missing (set CAPSULE_HERMES_ENDPOINT or "
33
+ "plugins.entries.capsule.endpoint in config.yaml)"
34
+ )
35
+ if not isinstance(token, str) or not token:
36
+ raise ValueError(
37
+ "capsule_hermes: token missing (set CAPSULE_HERMES_TOKEN or "
38
+ "plugins.entries.capsule.token in config.yaml)"
39
+ )
40
+
41
+ def _bool(key: str, default: bool) -> bool:
42
+ env = os.environ.get(f"CAPSULE_HERMES_{key.upper()}")
43
+ if env is not None:
44
+ return env.strip().lower() in {"1", "true", "yes", "on"}
45
+ v = raw.get(key, default)
46
+ return bool(v)
47
+
48
+ def _int(key: str, default: int) -> int:
49
+ env = os.environ.get(f"CAPSULE_HERMES_{key.upper()}")
50
+ if env is not None:
51
+ try:
52
+ return int(env)
53
+ except ValueError:
54
+ return default
55
+ v = raw.get(key, default)
56
+ try:
57
+ return int(v)
58
+ except (TypeError, ValueError):
59
+ return default
60
+
61
+ user_raw = raw.get("user")
62
+ return cls(
63
+ endpoint=endpoint,
64
+ token=token,
65
+ block_on_risk=_bool("block_on_risk", True),
66
+ fail_open=_bool("fail_open", True),
67
+ timeout_ms=_int("timeout_ms", 5000),
68
+ user=user_raw if isinstance(user_raw, str) else None,
69
+ )
@@ -0,0 +1,75 @@
1
+ """Normalize Hermes plugin-runtime kwargs into the Capsule backend DTO shape.
2
+
3
+ The Hermes Python runtime calls plugin hooks with flat kwargs (e.g.
4
+ `tool_name`, `args`, `session_id`, `task_id`). The Capsule agentsecurity
5
+ service expects a normalized envelope with a `context` block, `params`
6
+ instead of `args`, and a required ISO `timestamp`. This module owns that
7
+ translation so the rest of the plugin can stay shape-agnostic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Mapping
14
+
15
+
16
+ _CONTEXT_KEYS = (
17
+ "agent_id",
18
+ "session_id",
19
+ "run_id",
20
+ "parent_session_id",
21
+ "gateway",
22
+ "gateway_channel_id",
23
+ "user_id",
24
+ "trace_id",
25
+ "span_id",
26
+ )
27
+
28
+
29
+ def _iso_now() -> str:
30
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def _build_context(kwargs: Mapping[str, Any]) -> dict[str, Any]:
34
+ """Lift known context fields out of flat kwargs into a `context` dict.
35
+
36
+ `task_id` is mapped to `run_id` to match how the backend interprets the
37
+ per-task scope (a Hermes task ~= one agent run).
38
+ """
39
+ ctx: dict[str, Any] = {}
40
+ for key in _CONTEXT_KEYS:
41
+ v = kwargs.get(key)
42
+ if isinstance(v, str) and v:
43
+ ctx[key] = v
44
+ task_id = kwargs.get("task_id")
45
+ if isinstance(task_id, str) and task_id and "run_id" not in ctx:
46
+ ctx["run_id"] = task_id
47
+ return ctx
48
+
49
+
50
+ def normalize(hook_event_name: str, kwargs: Mapping[str, Any]) -> dict[str, Any]:
51
+ """Return the backend-shaped payload for a Hermes hook invocation.
52
+
53
+ Rules applied to every hook:
54
+ * `hook_event_name` is set explicitly.
55
+ * `timestamp` is filled with ISO-now if Hermes did not provide one.
56
+ * `context` collects session/run/agent/gateway identifiers so the
57
+ backend's `extractSessionId` / `generateAgentExternalId` work.
58
+
59
+ Per-hook rules:
60
+ * `pre_tool_call` / `post_tool_call`: rename `args` to `params`.
61
+ """
62
+ payload: dict[str, Any] = {
63
+ k: v
64
+ for k, v in kwargs.items()
65
+ if k not in _CONTEXT_KEYS and k != "task_id"
66
+ }
67
+ payload["hook_event_name"] = hook_event_name
68
+ payload["context"] = _build_context(kwargs)
69
+ payload.setdefault("timestamp", _iso_now())
70
+
71
+ if hook_event_name in {"pre_tool_call", "post_tool_call"}:
72
+ if "args" in payload and "params" not in payload:
73
+ payload["params"] = payload.pop("args")
74
+
75
+ return payload
@@ -0,0 +1,53 @@
1
+ """HTTP transport for forwarding Hermes hook events to the Capsule agentsecurity service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .config import CapsulePluginConfig
12
+ from .types import HermesHookEnvelope
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CapsuleResponse:
17
+ ok: bool
18
+ status: int
19
+ body: Any
20
+
21
+
22
+ class CapsuleTransport:
23
+ """Synchronous HTTP client that posts hook envelopes to Capsule.
24
+
25
+ Hermes invokes hook callbacks synchronously, so the transport is sync too.
26
+ Non-2xx responses are surfaced via `CapsuleResponse.ok` rather than raised
27
+ — the plugin's dispatch wrapper decides fail-open / fail-closed.
28
+ """
29
+
30
+ def __init__(self, config: CapsulePluginConfig) -> None:
31
+ self._config = config
32
+ url = config.endpoint.rstrip("/")
33
+ if not url.endswith("/hermes/hooks/events") and not url.endswith("/v1/hermes/hooks/events"):
34
+ url = f"{url}/v1/hermes/hooks/events"
35
+ self._url = url
36
+
37
+ def post(self, envelope: HermesHookEnvelope, correlation_id: str) -> CapsuleResponse:
38
+ headers = {
39
+ "Content-Type": "application/json",
40
+ "Authorization": f"Bearer {self._config.token}",
41
+ "X-Correlation-Id": correlation_id,
42
+ }
43
+ timeout = self._config.timeout_ms / 1000.0
44
+ with httpx.Client(timeout=timeout) as client:
45
+ response = client.post(self._url, json=envelope.to_dict(), headers=headers)
46
+ body: Any = None
47
+ text = response.text
48
+ if text:
49
+ try:
50
+ body = json.loads(text)
51
+ except json.JSONDecodeError:
52
+ body = text
53
+ return CapsuleResponse(ok=response.is_success, status=response.status_code, body=body)
@@ -0,0 +1,31 @@
1
+ """Shared payload shapes for the Capsule Hermes plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Mapping
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class HermesHookEnvelope:
11
+ """Top-level envelope POSTed to `/v1/hermes/hooks/events`.
12
+
13
+ Mirrors the request DTO the Capsule agentsecurity service accepts: the raw
14
+ Hermes hook payload plus the user identity and plugin version captured at
15
+ the source.
16
+ """
17
+
18
+ hook: Mapping[str, Any]
19
+ user: str
20
+ plugin_version: str
21
+ transcript_lines: list[str] = field(default_factory=list)
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ payload: dict[str, Any] = {
25
+ "hook": dict(self.hook),
26
+ "user": self.user,
27
+ "plugin_version": self.plugin_version,
28
+ }
29
+ if self.transcript_lines:
30
+ payload["transcript_lines"] = list(self.transcript_lines)
31
+ return payload