capsule-hermes 0.4.0__py3-none-any.whl

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,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
@@ -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,9 @@
1
+ capsule_hermes/__init__.py,sha256=_pHdzkRSxuv69cOaIOnUBf396dkGJUe1cPy3AJhH83w,4540
2
+ capsule_hermes/config.py,sha256=hx-lAATDwIj8H1gKl1a_hBijcidLbXAhmaKMx-frbnI,2413
3
+ capsule_hermes/normalize.py,sha256=SiqUOgzCqWhK8qSGn_7Weeny-TYGXJhk-UybVe84SQU,2430
4
+ capsule_hermes/transport.py,sha256=xTT1ADE7b0NWam_3RywSF7NrW8ZyEf9NtG65XaHW6Ls,1821
5
+ capsule_hermes/types.py,sha256=7JTAXVUE_F2httNIod25SR0f4HnTFTEtNDoDx9oFWb4,929
6
+ capsule_hermes-0.4.0.dist-info/METADATA,sha256=XHIxJ5RtcCkm93idvoCh0t4m87nxZg13GPf0eu93bYA,2728
7
+ capsule_hermes-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ capsule_hermes-0.4.0.dist-info/entry_points.txt,sha256=C9gefFW5FPZ5bAXkd0WX4hh_viS5W0x3iI-wZ-t-RYM,48
9
+ capsule_hermes-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ capsule = capsule_hermes