readme: document both hermes routes and the toolset grant

Fills in the subscription/wire name table for all five mappings rather
than the two prose examples, and records why the routes are written as
config instead of subscribed -- including that the toolset grant is
deliberate but not enforced, since the file it lives in is inside
HERMES_WRITE_SAFE_ROOT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
This commit is contained in:
2026-08-24 03:10:43 +02:00
co-authored by Claude Opus 5
parent 753573aeea
commit 152c38b56b
+51 -21
View File
@@ -39,14 +39,21 @@ run. Each service module opens its own firewall ports.
## Gitea events to Hermes ## Gitea events to Hermes
Jupiter's Gitea registers a webhook straight at Hermes on mars, Jupiter's Gitea registers one webhook per Hermes route, straight at Hermes on
`http://mars.orbit.sol:8644/webhooks/gitea-pr-comments`, with no relay in mars (`http://mars.orbit.sol:8644/webhooks/<route>`), with no relay in between:
between. Gitea's `addDefaultHeaders` signs every webhook type with
| route | subscribed gitea events | wakes luna on |
| --- | --- | --- |
| `gitea-pr-comments` | `pull_request_comment` | a timeline comment on a PR |
| `gitea-pr-reviews` | `pull_request_review_comment`, `pull_request_review_rejected` | a review with a body, or changes requested |
Approvals are deliberately not subscribed: an approval is darman signing off,
not asking for work. Gitea's `addDefaultHeaders` signs every webhook type with
`X-Hub-Signature-256` in GitHub's exact format and sends `X-GitHub-Event` `X-Hub-Signature-256` in GitHub's exact format and sends `X-GitHub-Event`
unconditionally — which is exactly what Hermes validates against the unconditionally — which is exactly what Hermes validates against the route
subscription secret and reads the event name from, so the two speak the same secret and reads the event name from, so the two speak the same protocol
protocol without translation. The URL path is the Hermes route name, so without translation. The URL path is the Hermes route name, so another route
another subscription is just another hook. is just another hook.
Gitea will only deliver to hosts in `[security] ALLOWED_HOST_LIST`, which Gitea will only deliver to hosts in `[security] ALLOWED_HOST_LIST`, which
defaults to `external` and does NOT include tailnet addresses defaults to `external` and does NOT include tailnet addresses
@@ -56,25 +63,48 @@ that, deliveries fail with `webhook can only call allowed HTTP servers`.
Gitea names webhook events twice, and the two namespaces collide. The hook's Gitea names webhook events twice, and the two namespaces collide. The hook's
`events` array takes the *subscription* name; `X-GitHub-Event`, which is what `events` array takes the *subscription* name; `X-GitHub-Event`, which is what
Hermes matches `--events` against, carries a lossy *wire* name from each Hermes route matches its `events` against, carries a lossy *wire* name
`HookEventType.Event()`. A comment on a PR subscribes as from `HookEventType.Event()`:
`pull_request_comment` but arrives as `issue_comment`, while
`pull_request_comment` on the wire means a review submission. So
`services/dev/gitea.nix` and `hosts/mars/hermes-agent.nix` deliberately name
the same event differently; `X-GitHub-Event-Type` carries the subscription
name, but Hermes does not read it.
The route's prompt and its filter script live in `hosts/mars/`, bind-mounted | subscription | wire | what it is |
read-only from the nix store so the agent cannot edit its own loop guard out, | --- | --- | --- |
and are re-subscribed by `hermes-agent-webhook-route` on every start. Run | `pull_request_comment` | `issue_comment` | comment on a PR |
`python3 hosts/mars/gitea-pr-comment-filter-test.py` after editing the filter. | `pull_request_review_comment` | `pull_request_comment` | review with a body |
| `pull_request_review_rejected` | `pull_request_rejected` | changes requested |
| `pull_request_review_approved` | `pull_request_approved` | approval |
So `services/dev/gitea.nix` and `hosts/mars/hermes-agent.nix` deliberately name
the same event differently, and neither is a typo. `X-GitHub-Event-Type`
carries the subscription name, but Hermes does not read it.
Each route's prompt and filter script live in `hosts/mars/`. The filters are
bind-mounted read-only from the nix store so the agent cannot edit her own
loop guard out; run
`python3 hosts/mars/gitea-pr-comment-filter-test.py` and
`python3 hosts/mars/gitea-pr-review-filter-test.py` after editing either.
`hermes-agent-webhook-routes` writes the routes into
`~/.hermes/webhook_subscriptions.json` directly, host-side, rather than
calling `hermes webhook subscribe`. That CLI has no `--toolsets` flag, and
without a toolset override a webhook run gets Hermes's constrained default
(`web_search`, `web_extract`, `vision_analyze`, `clarify`) — no shell, no file
access, so neither prompt can actually be carried out. Upstream's documented
answer is to add the `toolsets` key to that file by hand, which does not
survive a re-provision, so the whole route definition lives in nix instead.
The grant (`terminal`, `file`, `web`) is therefore deliberate and restored on
every start — but note it is not *enforced*: that file sits inside
`HERMES_WRITE_SAFE_ROOT`, so luna can widen her own toolset until the unit
next runs. The real backstop is gitea's branch protection on `master`.
Routes the unit does not name are left untouched, so retiring one is a manual
`sudo podman exec hermes-agent hermes webhook remove <name>` on mars — and
likewise its hook in the repo's Settings → Webhooks.
Before deploying either host, add the same random Before deploying either host, add the same random
`gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and `gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and
`secrets/jupiter.yaml` using `scripts/edit_secrets`, with no trailing newline `secrets/jupiter.yaml` using `scripts/edit_secrets`, with no trailing newline
the value reaches Hermes through an env-file template, where a newline both a newline would change the key the HMAC is computed with, and the two ends
corrupts the file and changes the key the HMAC is computed with. The value is would disagree. The value is intentionally not included in the repository.
intentionally not included in the repository.
## Test in VirtualBox (no hardware needed) ## Test in VirtualBox (no hardware needed)