agentguard-hermes-plugin 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.
- agentguard_hermes_plugin-0.1.0/.gitignore +3 -0
- agentguard_hermes_plugin-0.1.0/HOOK.yaml +11 -0
- agentguard_hermes_plugin-0.1.0/Makefile +22 -0
- agentguard_hermes_plugin-0.1.0/PKG-INFO +104 -0
- agentguard_hermes_plugin-0.1.0/README.md +84 -0
- agentguard_hermes_plugin-0.1.0/agentguard_hermes/__init__.py +1 -0
- agentguard_hermes_plugin-0.1.0/agentguard_hermes/cli.py +55 -0
- agentguard_hermes_plugin-0.1.0/agentguard_hermes/plugin.py +142 -0
- agentguard_hermes_plugin-0.1.0/agentguard_hermes/plugin.yaml +14 -0
- agentguard_hermes_plugin-0.1.0/handler.py +142 -0
- agentguard_hermes_plugin-0.1.0/pyproject.toml +33 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
name: agentguard
|
|
2
|
+
description: "Forwards Hermes gateway lifecycle events to AgentGuard for real-time security monitoring. NOTE: gateway hooks only fire when running 'hermes gateway' — not in CLI mode. For CLI coverage, use the plugin (plugin.py)."
|
|
3
|
+
events:
|
|
4
|
+
- gateway:startup
|
|
5
|
+
- session:start
|
|
6
|
+
- session:end
|
|
7
|
+
- session:reset
|
|
8
|
+
- agent:start
|
|
9
|
+
- agent:step
|
|
10
|
+
- agent:end
|
|
11
|
+
- command:*
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
PYTHON := python3.12
|
|
2
|
+
PYPIRC := .pypirc
|
|
3
|
+
|
|
4
|
+
.PHONY: build publish install clean
|
|
5
|
+
|
|
6
|
+
## Build sdist + wheel into dist/
|
|
7
|
+
build:
|
|
8
|
+
$(PYTHON) -m hatch build
|
|
9
|
+
|
|
10
|
+
## Upload to PyPI using .pypirc credentials
|
|
11
|
+
publish: build
|
|
12
|
+
$(PYTHON) -m pip install twine --break-system-packages --quiet
|
|
13
|
+
$(PYTHON) -m twine upload --config-file $(PYPIRC) dist/*
|
|
14
|
+
|
|
15
|
+
## Install the plugin into ~/.hermes/plugins/agentguard/ from local source
|
|
16
|
+
install:
|
|
17
|
+
$(PYTHON) -m pip install -e . --break-system-packages --quiet
|
|
18
|
+
agentguard-hermes install
|
|
19
|
+
|
|
20
|
+
## Remove build artifacts
|
|
21
|
+
clean:
|
|
22
|
+
rm -rf dist/ *.egg-info agentguard_hermes.egg-info
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentguard-hermes-plugin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AgentGuard security monitoring plugin for Hermes Agent
|
|
5
|
+
Project-URL: Homepage, https://github.com/timeplus/agentguard
|
|
6
|
+
Project-URL: Repository, https://github.com/timeplus/agentguard
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Keywords: agentguard,ai-agent,hermes,monitoring,observability,security
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# agentguard-hermes
|
|
22
|
+
|
|
23
|
+
AgentGuard security monitoring plugin for [Hermes Agent](https://github.com/nousresearch/hermes-agent).
|
|
24
|
+
|
|
25
|
+
Forwards Hermes session, LLM, and tool-call events to an [AgentGuard](https://github.com/timeplus/agentguard) Timeplus stream for real-time observability and security monitoring.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install agentguard-hermes-plugin
|
|
31
|
+
agentguard-hermes install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This copies the plugin into `~/.hermes/plugins/agentguard/` where Hermes auto-loads it on startup.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Set environment variables before starting Hermes:
|
|
39
|
+
|
|
40
|
+
| Variable | Default | Description |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `AGENTGUARD_TIMEPLUS_URL` | `http://localhost:3218` | Timeplus Enterprise HTTP endpoint |
|
|
43
|
+
| `AGENTGUARD_USERNAME` | `proton` | Timeplus username |
|
|
44
|
+
| `AGENTGUARD_PASSWORD` | _(empty)_ | Timeplus password |
|
|
45
|
+
| `AGENTGUARD_AGENT_ID` | hostname | Identifier for this agent instance |
|
|
46
|
+
| `AGENTGUARD_DEPLOYMENT_ID` | `local` | Deployment environment tag |
|
|
47
|
+
| `AGENTGUARD_DEPLOYMENT_NAME` | `Local Dev` | Human-readable deployment name |
|
|
48
|
+
| `AGENTGUARD_STREAM` | `agentguard_hook_events` | Target Timeplus stream |
|
|
49
|
+
|
|
50
|
+
### Example
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export AGENTGUARD_TIMEPLUS_URL=http://timeplus.example.com:3218
|
|
54
|
+
export AGENTGUARD_USERNAME=proton
|
|
55
|
+
export AGENTGUARD_PASSWORD=secret
|
|
56
|
+
export AGENTGUARD_DEPLOYMENT_ID=production
|
|
57
|
+
export AGENTGUARD_DEPLOYMENT_NAME="Production Hermes"
|
|
58
|
+
|
|
59
|
+
hermes
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What gets captured
|
|
63
|
+
|
|
64
|
+
Every Hermes hook event is forwarded to the `agentguard_hook_events` stream:
|
|
65
|
+
|
|
66
|
+
| Hermes hook | Sent as | Description |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `on_session_start` | `on_session_start` | New session begins |
|
|
69
|
+
| `on_session_end` | `conversation_end` | Single chat turn completed |
|
|
70
|
+
| `on_session_finalize` | `on_session_end` | Session fully torn down (CLI exit, `/reset`) |
|
|
71
|
+
| `on_session_reset` | `on_session_reset` | Session rotated via `/new` |
|
|
72
|
+
| `pre_llm_call` | `pre_llm_call` | Before each LLM turn |
|
|
73
|
+
| `post_llm_call` | `post_llm_call` | After each LLM turn |
|
|
74
|
+
| `pre_api_request` | `pre_api_request` | Before each raw API call (token metrics) |
|
|
75
|
+
| `post_api_request` | `post_api_request` | After each raw API call |
|
|
76
|
+
| `pre_tool_call` | `pre_tool_call` | Before each tool execution |
|
|
77
|
+
| `post_tool_call` | `post_tool_call` | After each tool execution |
|
|
78
|
+
|
|
79
|
+
`conversation_history` is stripped from all events before ingestion — it is unbounded and contains no information not already available from the individual turn events.
|
|
80
|
+
|
|
81
|
+
## Custom install path
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
agentguard-hermes install --hermes-dir /path/to/hermes/data
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Manual installation (Makefile / Docker)
|
|
88
|
+
|
|
89
|
+
If you run Hermes via the provided Docker Compose setup in `agents/hermes/`:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
make configure # copies plugin into .hermes/plugins/agentguard/
|
|
93
|
+
make cli # start Hermes CLI with AgentGuard env vars pre-set
|
|
94
|
+
make start # start Hermes gateway + web dashboard
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Publishing to PyPI
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install hatch
|
|
101
|
+
cd agents/hermes/agentguard-plugin
|
|
102
|
+
hatch build # produces dist/agentguard_hermes_plugin-*.whl
|
|
103
|
+
hatch publish
|
|
104
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# agentguard-hermes
|
|
2
|
+
|
|
3
|
+
AgentGuard security monitoring plugin for [Hermes Agent](https://github.com/nousresearch/hermes-agent).
|
|
4
|
+
|
|
5
|
+
Forwards Hermes session, LLM, and tool-call events to an [AgentGuard](https://github.com/timeplus/agentguard) Timeplus stream for real-time observability and security monitoring.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install agentguard-hermes-plugin
|
|
11
|
+
agentguard-hermes install
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This copies the plugin into `~/.hermes/plugins/agentguard/` where Hermes auto-loads it on startup.
|
|
15
|
+
|
|
16
|
+
## Configuration
|
|
17
|
+
|
|
18
|
+
Set environment variables before starting Hermes:
|
|
19
|
+
|
|
20
|
+
| Variable | Default | Description |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `AGENTGUARD_TIMEPLUS_URL` | `http://localhost:3218` | Timeplus Enterprise HTTP endpoint |
|
|
23
|
+
| `AGENTGUARD_USERNAME` | `proton` | Timeplus username |
|
|
24
|
+
| `AGENTGUARD_PASSWORD` | _(empty)_ | Timeplus password |
|
|
25
|
+
| `AGENTGUARD_AGENT_ID` | hostname | Identifier for this agent instance |
|
|
26
|
+
| `AGENTGUARD_DEPLOYMENT_ID` | `local` | Deployment environment tag |
|
|
27
|
+
| `AGENTGUARD_DEPLOYMENT_NAME` | `Local Dev` | Human-readable deployment name |
|
|
28
|
+
| `AGENTGUARD_STREAM` | `agentguard_hook_events` | Target Timeplus stream |
|
|
29
|
+
|
|
30
|
+
### Example
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export AGENTGUARD_TIMEPLUS_URL=http://timeplus.example.com:3218
|
|
34
|
+
export AGENTGUARD_USERNAME=proton
|
|
35
|
+
export AGENTGUARD_PASSWORD=secret
|
|
36
|
+
export AGENTGUARD_DEPLOYMENT_ID=production
|
|
37
|
+
export AGENTGUARD_DEPLOYMENT_NAME="Production Hermes"
|
|
38
|
+
|
|
39
|
+
hermes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What gets captured
|
|
43
|
+
|
|
44
|
+
Every Hermes hook event is forwarded to the `agentguard_hook_events` stream:
|
|
45
|
+
|
|
46
|
+
| Hermes hook | Sent as | Description |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `on_session_start` | `on_session_start` | New session begins |
|
|
49
|
+
| `on_session_end` | `conversation_end` | Single chat turn completed |
|
|
50
|
+
| `on_session_finalize` | `on_session_end` | Session fully torn down (CLI exit, `/reset`) |
|
|
51
|
+
| `on_session_reset` | `on_session_reset` | Session rotated via `/new` |
|
|
52
|
+
| `pre_llm_call` | `pre_llm_call` | Before each LLM turn |
|
|
53
|
+
| `post_llm_call` | `post_llm_call` | After each LLM turn |
|
|
54
|
+
| `pre_api_request` | `pre_api_request` | Before each raw API call (token metrics) |
|
|
55
|
+
| `post_api_request` | `post_api_request` | After each raw API call |
|
|
56
|
+
| `pre_tool_call` | `pre_tool_call` | Before each tool execution |
|
|
57
|
+
| `post_tool_call` | `post_tool_call` | After each tool execution |
|
|
58
|
+
|
|
59
|
+
`conversation_history` is stripped from all events before ingestion — it is unbounded and contains no information not already available from the individual turn events.
|
|
60
|
+
|
|
61
|
+
## Custom install path
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
agentguard-hermes install --hermes-dir /path/to/hermes/data
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Manual installation (Makefile / Docker)
|
|
68
|
+
|
|
69
|
+
If you run Hermes via the provided Docker Compose setup in `agents/hermes/`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
make configure # copies plugin into .hermes/plugins/agentguard/
|
|
73
|
+
make cli # start Hermes CLI with AgentGuard env vars pre-set
|
|
74
|
+
make start # start Hermes gateway + web dashboard
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Publishing to PyPI
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install hatch
|
|
81
|
+
cd agents/hermes/agentguard-plugin
|
|
82
|
+
hatch build # produces dist/agentguard_hermes_plugin-*.whl
|
|
83
|
+
hatch publish
|
|
84
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import importlib.resources
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="agentguard-hermes",
|
|
11
|
+
description="AgentGuard security monitoring plugin for Hermes Agent",
|
|
12
|
+
)
|
|
13
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
install_cmd = sub.add_parser("install", help="Install the plugin into Hermes data directory")
|
|
16
|
+
install_cmd.add_argument(
|
|
17
|
+
"--hermes-dir",
|
|
18
|
+
default=os.path.expanduser("~/.hermes"),
|
|
19
|
+
metavar="DIR",
|
|
20
|
+
help="Hermes data directory (default: ~/.hermes)",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
|
|
25
|
+
if args.command == "install":
|
|
26
|
+
_install(args.hermes_dir)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _install(hermes_dir: str) -> None:
|
|
30
|
+
plugin_dir = os.path.join(hermes_dir, "plugins", "agentguard")
|
|
31
|
+
os.makedirs(plugin_dir, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
pkg = importlib.resources.files("agentguard_hermes")
|
|
34
|
+
|
|
35
|
+
with importlib.resources.as_file(pkg / "plugin.yaml") as src:
|
|
36
|
+
shutil.copy2(src, os.path.join(plugin_dir, "plugin.yaml"))
|
|
37
|
+
|
|
38
|
+
with importlib.resources.as_file(pkg / "plugin.py") as src:
|
|
39
|
+
shutil.copy2(src, os.path.join(plugin_dir, "__init__.py"))
|
|
40
|
+
|
|
41
|
+
print(f"AgentGuard plugin installed → {plugin_dir}")
|
|
42
|
+
print()
|
|
43
|
+
print("Configure via environment variables before starting Hermes:")
|
|
44
|
+
print()
|
|
45
|
+
print(" AGENTGUARD_TIMEPLUS_URL (default: http://localhost:3218)")
|
|
46
|
+
print(" AGENTGUARD_USERNAME (default: proton)")
|
|
47
|
+
print(" AGENTGUARD_PASSWORD")
|
|
48
|
+
print(" AGENTGUARD_AGENT_ID (default: hostname)")
|
|
49
|
+
print(" AGENTGUARD_DEPLOYMENT_ID (default: local)")
|
|
50
|
+
print(" AGENTGUARD_DEPLOYMENT_NAME (default: Local Dev)")
|
|
51
|
+
print(" AGENTGUARD_STREAM (default: agentguard_hook_events)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentGuard plugin for Hermes Agent.
|
|
3
|
+
|
|
4
|
+
Forwards raw Hermes plugin hook events to the AgentGuard Timeplus stream.
|
|
5
|
+
All kwargs are stored verbatim in event_data. conversation_history is dropped
|
|
6
|
+
— it only contains user/assistant turns (no system prompt) and grows
|
|
7
|
+
unboundedly. The system prompt is not exposed by any Hermes plugin hook.
|
|
8
|
+
|
|
9
|
+
Plugin hooks (CLI + gateway):
|
|
10
|
+
on_session_start session_id, model, platform
|
|
11
|
+
on_session_end session_id, completed, interrupted, model, platform
|
|
12
|
+
on_session_finalize session_id (may be None), platform
|
|
13
|
+
on_session_reset session_id, platform
|
|
14
|
+
pre_llm_call session_id, conversation_history, is_first_turn, model, platform, sender_id
|
|
15
|
+
post_llm_call session_id, conversation_history, model, platform
|
|
16
|
+
pre_tool_call tool_name, args, task_id, session_id, tool_call_id
|
|
17
|
+
post_tool_call tool_name, args, result, task_id, session_id, tool_call_id
|
|
18
|
+
pre_api_request session_id, model, provider, approx_input_tokens, message_count, ...
|
|
19
|
+
post_api_request session_id, model, provider, usage{tokens}, api_duration, ...
|
|
20
|
+
|
|
21
|
+
Hook name mapping (Hermes → AgentGuard):
|
|
22
|
+
on_session_end → "conversation_end" (fires per chat turn, not true session end)
|
|
23
|
+
on_session_finalize → "on_session_end" (true session teardown)
|
|
24
|
+
all others → unchanged
|
|
25
|
+
|
|
26
|
+
Run ID: session_id (all events in a session share the same run_id).
|
|
27
|
+
|
|
28
|
+
Install: drop plugin.yaml + __init__.py into ~/.hermes/plugins/agentguard/
|
|
29
|
+
|
|
30
|
+
Config via environment variables:
|
|
31
|
+
AGENTGUARD_TIMEPLUS_URL AGENTGUARD_STREAM AGENTGUARD_USERNAME
|
|
32
|
+
AGENTGUARD_PASSWORD AGENTGUARD_AGENT_ID AGENTGUARD_DEPLOYMENT_ID
|
|
33
|
+
AGENTGUARD_DEPLOYMENT_NAME
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import socket
|
|
39
|
+
import urllib.error
|
|
40
|
+
import urllib.request
|
|
41
|
+
from base64 import b64encode
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
|
|
44
|
+
_TIMEPLUS_URL = os.environ.get("AGENTGUARD_TIMEPLUS_URL", "http://localhost:3218")
|
|
45
|
+
_STREAM = os.environ.get("AGENTGUARD_STREAM", "agentguard_hook_events")
|
|
46
|
+
_USERNAME = os.environ.get("AGENTGUARD_USERNAME", "proton")
|
|
47
|
+
_PASSWORD = os.environ.get("AGENTGUARD_PASSWORD", "")
|
|
48
|
+
_AGENT_ID = os.environ.get("AGENTGUARD_AGENT_ID") or socket.gethostname()
|
|
49
|
+
_DEPLOYMENT_ID = os.environ.get("AGENTGUARD_DEPLOYMENT_ID", "local")
|
|
50
|
+
_DEPLOYMENT_NAME = os.environ.get("AGENTGUARD_DEPLOYMENT_NAME", "Local Dev")
|
|
51
|
+
|
|
52
|
+
_COLUMNS = [
|
|
53
|
+
"hook_name", "event_time", "session_id", "run_id",
|
|
54
|
+
"agent_id", "deployment_id", "deployment_name", "session_key",
|
|
55
|
+
"tool_name", "provider", "model", "hook_decision",
|
|
56
|
+
"block_reason", "event_data", "agent_type",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
_SKIP_KEYS = {"conversation_history"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ingest(rows: list) -> None:
|
|
63
|
+
url = f"{_TIMEPLUS_URL.rstrip('/')}/proton/v1/ingest/streams/{_STREAM}"
|
|
64
|
+
payload = json.dumps({"columns": _COLUMNS, "data": rows}).encode()
|
|
65
|
+
req = urllib.request.Request(url, data=payload, method="POST")
|
|
66
|
+
req.add_header("Content-Type", "application/json")
|
|
67
|
+
if _USERNAME:
|
|
68
|
+
token = b64encode(f"{_USERNAME}:{_PASSWORD}".encode()).decode()
|
|
69
|
+
req.add_header("Authorization", f"Basic {token}")
|
|
70
|
+
try:
|
|
71
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
72
|
+
resp.read()
|
|
73
|
+
except urllib.error.URLError as e:
|
|
74
|
+
print(f"[agentguard] ingest error: {e}", flush=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_row(hook_name: str, kwargs: dict, *, session_id: str = None) -> list:
|
|
78
|
+
sid = session_id if session_id is not None else (
|
|
79
|
+
kwargs.get("session_id") or kwargs.get("task_id") or ""
|
|
80
|
+
)
|
|
81
|
+
data = {k: v for k, v in kwargs.items() if k not in _SKIP_KEYS}
|
|
82
|
+
return [
|
|
83
|
+
hook_name,
|
|
84
|
+
datetime.now(timezone.utc).isoformat(),
|
|
85
|
+
sid,
|
|
86
|
+
sid, # run_id = session_id
|
|
87
|
+
_AGENT_ID,
|
|
88
|
+
_DEPLOYMENT_ID,
|
|
89
|
+
_DEPLOYMENT_NAME,
|
|
90
|
+
"", # session_key — not available in plugin hooks
|
|
91
|
+
kwargs.get("tool_name") or "",
|
|
92
|
+
kwargs.get("provider") or "",
|
|
93
|
+
kwargs.get("model") or "",
|
|
94
|
+
"observe",
|
|
95
|
+
"",
|
|
96
|
+
json.dumps(data, default=str),
|
|
97
|
+
"hermes",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _hook(hook_name: str):
|
|
102
|
+
def _cb(**kwargs):
|
|
103
|
+
_ingest([_make_row(hook_name, kwargs)])
|
|
104
|
+
_cb.__name__ = f"_{hook_name}"
|
|
105
|
+
return _cb
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Lifecycle hooks with non-trivial semantics
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _on_session_end(**kwargs):
|
|
113
|
+
"""
|
|
114
|
+
Hermes fires this after every run_conversation() call (per chat turn).
|
|
115
|
+
Reported as 'conversation_end' — NOT treated as session termination by AgentGuard.
|
|
116
|
+
"""
|
|
117
|
+
_ingest([_make_row("conversation_end", kwargs)])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _on_session_finalize(**kwargs):
|
|
121
|
+
"""
|
|
122
|
+
True session teardown (CLI exit, /new, /reset, gateway GC).
|
|
123
|
+
Reported as 'on_session_end' so AgentGuard marks the session as closed.
|
|
124
|
+
"""
|
|
125
|
+
sid = kwargs.get("session_id") or ""
|
|
126
|
+
_ingest([_make_row("on_session_end", kwargs, session_id=sid)])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Plugin registration — called by Hermes on plugin load
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
def register(ctx):
|
|
133
|
+
ctx.register_hook("on_session_start", _hook("on_session_start"))
|
|
134
|
+
ctx.register_hook("on_session_end", _on_session_end) # per-turn → conversation_end
|
|
135
|
+
ctx.register_hook("on_session_finalize", _on_session_finalize) # true end → on_session_end
|
|
136
|
+
ctx.register_hook("on_session_reset", _hook("on_session_reset"))
|
|
137
|
+
ctx.register_hook("pre_llm_call", _hook("pre_llm_call"))
|
|
138
|
+
ctx.register_hook("post_llm_call", _hook("post_llm_call"))
|
|
139
|
+
ctx.register_hook("pre_api_request", _hook("pre_api_request"))
|
|
140
|
+
ctx.register_hook("post_api_request", _hook("post_api_request"))
|
|
141
|
+
ctx.register_hook("pre_tool_call", _hook("pre_tool_call"))
|
|
142
|
+
ctx.register_hook("post_tool_call", _hook("post_tool_call"))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: agentguard
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
description: "Forwards Hermes session and tool events to AgentGuard for real-time security monitoring."
|
|
4
|
+
hooks:
|
|
5
|
+
- on_session_start
|
|
6
|
+
- on_session_end
|
|
7
|
+
- on_session_finalize
|
|
8
|
+
- on_session_reset
|
|
9
|
+
- pre_llm_call
|
|
10
|
+
- post_llm_call
|
|
11
|
+
- pre_api_request
|
|
12
|
+
- post_api_request
|
|
13
|
+
- pre_tool_call
|
|
14
|
+
- post_tool_call
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentGuard gateway hook for Hermes Agent.
|
|
3
|
+
|
|
4
|
+
Forwards Hermes gateway lifecycle events to the agentguard_hook_events Timeplus
|
|
5
|
+
stream for real-time security monitoring.
|
|
6
|
+
|
|
7
|
+
IMPORTANT: Gateway hooks only fire when Hermes runs in gateway mode
|
|
8
|
+
('hermes gateway run' / Docker). They do NOT fire in CLI mode ('hermes').
|
|
9
|
+
For CLI + gateway coverage, use the companion plugin (plugin.py).
|
|
10
|
+
|
|
11
|
+
Installed into ~/.hermes/hooks/agentguard/ via 'make configure'.
|
|
12
|
+
|
|
13
|
+
Events captured (from ~/.hermes/hooks/agentguard/HOOK.yaml):
|
|
14
|
+
gateway:startup — gateway process started
|
|
15
|
+
session:start — new messaging session created
|
|
16
|
+
session:end — session ended
|
|
17
|
+
session:reset — user ran /new or /reset
|
|
18
|
+
agent:start — agent begins processing a message
|
|
19
|
+
agent:step — each tool-calling loop iteration
|
|
20
|
+
agent:end — agent finished processing
|
|
21
|
+
command:* — any slash command executed
|
|
22
|
+
|
|
23
|
+
Context keys per event (from official docs):
|
|
24
|
+
gateway:startup → { platforms }
|
|
25
|
+
session:start → { platform, user_id, session_id, session_key }
|
|
26
|
+
session:end → { platform, user_id, session_key }
|
|
27
|
+
session:reset → { platform, user_id, session_key }
|
|
28
|
+
agent:start → { platform, user_id, session_id, message }
|
|
29
|
+
agent:step → { platform, user_id, session_id, iteration, tool_names }
|
|
30
|
+
agent:end → { platform, user_id, session_id, message, response }
|
|
31
|
+
command:* → { platform, user_id, command, args }
|
|
32
|
+
|
|
33
|
+
Config via environment variables:
|
|
34
|
+
AGENTGUARD_TIMEPLUS_URL — Timeplus HTTP endpoint (default: http://localhost:3218)
|
|
35
|
+
AGENTGUARD_STREAM — target stream name (default: agentguard_hook_events)
|
|
36
|
+
AGENTGUARD_USERNAME — Timeplus username (default: proton)
|
|
37
|
+
AGENTGUARD_PASSWORD — Timeplus password (default: empty)
|
|
38
|
+
AGENTGUARD_AGENT_ID — agent identifier (default: hostname)
|
|
39
|
+
AGENTGUARD_DEPLOYMENT_ID — deployment tag (default: local)
|
|
40
|
+
AGENTGUARD_DEPLOYMENT_NAME — human-readable deployment name (default: Local Dev)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import socket
|
|
46
|
+
import urllib.error
|
|
47
|
+
import urllib.request
|
|
48
|
+
from base64 import b64encode
|
|
49
|
+
from datetime import datetime, timezone
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Config
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
_TIMEPLUS_URL = os.environ.get("AGENTGUARD_TIMEPLUS_URL", "http://localhost:3218")
|
|
55
|
+
_STREAM = os.environ.get("AGENTGUARD_STREAM", "agentguard_hook_events")
|
|
56
|
+
_USERNAME = os.environ.get("AGENTGUARD_USERNAME", "proton")
|
|
57
|
+
_PASSWORD = os.environ.get("AGENTGUARD_PASSWORD", "")
|
|
58
|
+
_AGENT_ID = os.environ.get("AGENTGUARD_AGENT_ID") or socket.gethostname()
|
|
59
|
+
_DEPLOYMENT_ID = os.environ.get("AGENTGUARD_DEPLOYMENT_ID", "local")
|
|
60
|
+
_DEPLOYMENT_NAME = os.environ.get("AGENTGUARD_DEPLOYMENT_NAME", "Local Dev")
|
|
61
|
+
|
|
62
|
+
_COLUMNS = [
|
|
63
|
+
"hook_name", "event_time", "session_id", "run_id",
|
|
64
|
+
"agent_id", "deployment_id", "deployment_name", "session_key",
|
|
65
|
+
"tool_name", "provider", "model", "hook_decision",
|
|
66
|
+
"block_reason", "event_data", "agent_type",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Hermes gateway event → AgentGuard hook_name
|
|
70
|
+
_EVENT_MAP = {
|
|
71
|
+
"gateway:startup": "session_start", # gateway process start; no session yet
|
|
72
|
+
"session:start": "session_start",
|
|
73
|
+
"session:end": "session_end",
|
|
74
|
+
"session:reset": "session_end",
|
|
75
|
+
"agent:start": "user_prompt_submit",
|
|
76
|
+
"agent:step": "before_tool_call",
|
|
77
|
+
"agent:end": "llm_output",
|
|
78
|
+
# command:* is handled via wildcard match — mapped to "permission_denied" equivalent
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Ingest
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
def _ingest(rows: list) -> None:
|
|
85
|
+
url = f"{_TIMEPLUS_URL.rstrip('/')}/proton/v1/ingest/streams/{_STREAM}"
|
|
86
|
+
payload = json.dumps({"columns": _COLUMNS, "data": rows}).encode()
|
|
87
|
+
req = urllib.request.Request(url, data=payload, method="POST")
|
|
88
|
+
req.add_header("Content-Type", "application/json")
|
|
89
|
+
if _USERNAME:
|
|
90
|
+
token = b64encode(f"{_USERNAME}:{_PASSWORD}".encode()).decode()
|
|
91
|
+
req.add_header("Authorization", f"Basic {token}")
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
94
|
+
resp.read()
|
|
95
|
+
except urllib.error.URLError as e:
|
|
96
|
+
print(f"[agentguard] ingest error: {e}", flush=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _row(hook_name: str, ctx: dict, tool_name: str = "", model: str = "") -> list:
|
|
100
|
+
return [
|
|
101
|
+
hook_name,
|
|
102
|
+
datetime.now(timezone.utc).isoformat(),
|
|
103
|
+
ctx.get("session_id", ""),
|
|
104
|
+
ctx.get("session_id", ""), # run_id mirrors session_id
|
|
105
|
+
_AGENT_ID,
|
|
106
|
+
_DEPLOYMENT_ID,
|
|
107
|
+
_DEPLOYMENT_NAME,
|
|
108
|
+
ctx.get("session_key", ""),
|
|
109
|
+
tool_name,
|
|
110
|
+
"", # provider — not exposed by gateway hooks
|
|
111
|
+
model,
|
|
112
|
+
"observe",
|
|
113
|
+
"",
|
|
114
|
+
json.dumps(ctx),
|
|
115
|
+
"hermes",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Hook entry point — called by Hermes for each registered event
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
def handle(event_type: str, context: dict) -> None:
|
|
123
|
+
# command:* wildcard — map all slash commands to a dedicated hook_name
|
|
124
|
+
if event_type.startswith("command:"):
|
|
125
|
+
cmd = context.get("command", event_type.replace("command:", ""))
|
|
126
|
+
rows = [_row("slash_command", context, tool_name=cmd)]
|
|
127
|
+
_ingest(rows)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
hook_name = _EVENT_MAP.get(event_type)
|
|
131
|
+
if hook_name is None:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# agent:step carries a list of tool names — emit one row per tool so
|
|
135
|
+
# AgentGuard rule MVs can match on individual tool_name values.
|
|
136
|
+
if event_type == "agent:step":
|
|
137
|
+
tool_names = context.get("tool_names") or []
|
|
138
|
+
rows = [_row(hook_name, context, t) for t in tool_names] if tool_names else [_row(hook_name, context)]
|
|
139
|
+
else:
|
|
140
|
+
rows = [_row(hook_name, context)]
|
|
141
|
+
|
|
142
|
+
_ingest(rows)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentguard-hermes-plugin"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AgentGuard security monitoring plugin for Hermes Agent"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["agentguard", "hermes", "security", "ai-agent", "monitoring", "observability"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/timeplus/agentguard"
|
|
27
|
+
Repository = "https://github.com/timeplus/agentguard"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
agentguard-hermes = "agentguard_hermes.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["agentguard_hermes"]
|