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.
- capsule_hermes-0.4.0/.gitignore +278 -0
- capsule_hermes-0.4.0/PKG-INFO +60 -0
- capsule_hermes-0.4.0/README.md +44 -0
- capsule_hermes-0.4.0/pyproject.toml +38 -0
- capsule_hermes-0.4.0/src/capsule_hermes/__init__.py +146 -0
- capsule_hermes-0.4.0/src/capsule_hermes/config.py +69 -0
- capsule_hermes-0.4.0/src/capsule_hermes/normalize.py +75 -0
- capsule_hermes-0.4.0/src/capsule_hermes/transport.py +53 -0
- capsule_hermes-0.4.0/src/capsule_hermes/types.py +31 -0
|
@@ -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
|