hermes: add read-only Gitea PR comment filter, break the reply loop
The gitea-events subscription woke the agent on every delivery. That is an
unbounded loop as soon as she is given a prompt that tells her to answer on
the PR: her answer is itself a pull_request_comment, which wakes her again.
Adds a Hermes route script that drops the deliveries that must never reach an
LLM call: luna's own comments (the loop guard), "deleted" actions (the body
is still in the payload, so acting on one means acting on a request that was
explicitly withdrawn), non-pull-request comments, empty bodies, and edits
that did not actually change the body — a label or attachment change fires
"edited" too. Everything else passes through unchanged.
Mounted READ-ONLY from the nix store rather than written into hermesHome.
Hermes resolves route scripts under ~/.hermes/scripts, which here is inside
/opt/data — HERMES_WRITE_SAFE_ROOT — so a filter written there would be a
loop guard sitting in the writable root of the agent it constrains. Deleting
it fails closed (Hermes treats a missing script as "ignore"), but rewriting
it to always-allow would silently restore the loop. Read-only from the store
makes that impossible and keeps the guard in git.
The script also normalises changes.body.from to always exist. Gitea omits
`changes` entirely on created events, and Hermes replaces the prompt payload
with whatever JSON the script emits, so guaranteeing the key here means a
prompt referencing {changes.body.from} renders empty instead of leaving an
unfilled placeholder.
Note the stdout contract (gateway/platforms/webhook.py): only exactly
"[SILENT]", empty output, or a nonzero exit drop a delivery. Any OTHER text
on stdout lets it through and is attached as script_output — so a stray
debug print would silently defeat the filter. All diagnostics go to stderr,
and gitea-pr-comment-filter-test.py asserts that discipline along with each
drop rule (25 cases). Run it after any edit: the fail-closed behaviour means
a syntax error produces silence, not an error.
--events is still unset; event selection remains runtime-tunable policy.
The filter covers only what must not be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Contract test for gitea-pr-comment-filter.py.
|
||||
|
||||
Hermes treats "[SILENT]"/empty/nonzero-exit as ignore, a JSON object as a
|
||||
payload replacement, and ANY OTHER stdout text as allow-with-script_output.
|
||||
So each case asserts on the exact stdout discipline, not just the decision.
|
||||
"""
|
||||
import json, subprocess, sys, pathlib
|
||||
|
||||
SCRIPT = str(pathlib.Path(__file__).with_name("gitea-pr-comment-filter.py"))
|
||||
|
||||
def payload(action="created", author="darman", body="please fix the typo",
|
||||
previous=None, is_pull=True, cid=42, number=7):
|
||||
p = {"action": action, "is_pull": is_pull,
|
||||
"comment": {"id": cid, "body": body, "user": {"login": author},
|
||||
"html_url": "https://git.mgaction.town/darman/homelab/pulls/7#issuecomment-42"},
|
||||
"issue": {"number": number, "title": "some PR"},
|
||||
"repository": {"full_name": "darman/homelab"},
|
||||
"sender": {"login": author}}
|
||||
if previous is not None:
|
||||
p["changes"] = {"body": {"from": previous}}
|
||||
return p
|
||||
|
||||
def run(p):
|
||||
r = subprocess.run([sys.executable, SCRIPT], input=json.dumps(p),
|
||||
capture_output=True, text=True)
|
||||
return r.returncode, r.stdout, r.stderr
|
||||
|
||||
def classify(rc, out):
|
||||
"""Replicate Hermes's own interpretation of the script result."""
|
||||
if rc != 0 or out.strip() == "" or out.strip() == "[SILENT]":
|
||||
return "IGNORED"
|
||||
try:
|
||||
v = json.loads(out)
|
||||
return "ALLOWED" if isinstance(v, dict) else "ALLOWED(script_output)"
|
||||
except ValueError:
|
||||
return "ALLOWED(script_output)"
|
||||
|
||||
fails = []
|
||||
def check(name, p, expect):
|
||||
rc, out, err = run(p)
|
||||
got = classify(rc, out)
|
||||
ok = got == expect
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name:<52} {got}")
|
||||
if not ok:
|
||||
fails.append(name); print(f" expected {expect}; stdout={out!r} stderr={err.strip()!r}")
|
||||
return out
|
||||
|
||||
# --- the loop guard, the whole reason this exists ---
|
||||
check("luna's own comment is dropped (LOOP GUARD)", payload(author="luna"), "IGNORED")
|
||||
check("luna in different case is dropped", payload(author="LUNA"), "IGNORED")
|
||||
|
||||
# --- action handling ---
|
||||
check("created by human is allowed", payload(), "ALLOWED")
|
||||
check("deleted is dropped", payload(action="deleted"), "IGNORED")
|
||||
check("edited with changed body is allowed",
|
||||
payload(action="edited", body="new text", previous="old text"), "ALLOWED")
|
||||
check("edited with unchanged body is dropped",
|
||||
payload(action="edited", body="same", previous="same"), "IGNORED")
|
||||
check("unknown action is dropped", payload(action="reopened"), "IGNORED")
|
||||
|
||||
# --- misc guards ---
|
||||
check("issue comment (is_pull=false) is dropped", payload(is_pull=False), "IGNORED")
|
||||
check("empty body is dropped", payload(body=" "), "IGNORED")
|
||||
check("missing comment object is dropped", {"action": "created"}, "IGNORED")
|
||||
check("malformed payload is dropped", "not-a-dict", "IGNORED")
|
||||
|
||||
# --- normalisation: the prompt's {changes.body.from} must always resolve ---
|
||||
out = check("created event still allowed", payload(), "ALLOWED")
|
||||
norm = json.loads(out)
|
||||
c1 = norm.get("changes", {}).get("body", {}).get("from")
|
||||
print(f"{'PASS' if c1 == '' else 'FAIL'} {'created: changes.body.from normalised to empty':<52} {c1!r}")
|
||||
if c1 != "": fails.append("normalise-created")
|
||||
|
||||
out = check("edited event still allowed", payload(action="edited", body="new", previous="old"), "ALLOWED")
|
||||
c2 = json.loads(out).get("changes", {}).get("body", {}).get("from")
|
||||
print(f"{'PASS' if c2 == 'old' else 'FAIL'} {'edited: changes.body.from preserved':<52} {c2!r}")
|
||||
if c2 != "old": fails.append("normalise-edited")
|
||||
|
||||
# --- payload passthrough: prompt paths must survive the transform ---
|
||||
norm = json.loads(run(payload())[1])
|
||||
for path in [("comment","id"), ("comment","body"), ("comment","user","login"),
|
||||
("comment","html_url"), ("issue","number"), ("issue","title"),
|
||||
("repository","full_name"), ("action",)]:
|
||||
cur, ok = norm, True
|
||||
for k in path:
|
||||
if isinstance(cur, dict) and k in cur: cur = cur[k]
|
||||
else: ok = False; break
|
||||
label = ".".join(path)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {'prompt path survives: {' + label + '}':<52} {cur if ok else 'MISSING'}")
|
||||
if not ok: fails.append(f"path-{label}")
|
||||
|
||||
# --- stdout discipline: an ignore must emit EXACTLY [SILENT] ---
|
||||
rc, out, err = run(payload(author="luna"))
|
||||
print(f"{'PASS' if out == chr(91)+'SILENT'+chr(93)+chr(10) else 'FAIL'} {'ignore emits exactly [SILENT] on stdout':<52} {out!r}")
|
||||
if out != "[SILENT]\n": fails.append("silent-exact")
|
||||
print(f"{'PASS' if err.strip() else 'FAIL'} {'ignore explains itself on stderr':<52} {err.strip()[:40]!r}")
|
||||
if not err.strip(): fails.append("stderr-reason")
|
||||
|
||||
print()
|
||||
print("ALL PASSED" if not fails else "FAILURES: " + ", ".join(fails))
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hermes webhook filter for Gitea pull_request_comment deliveries.
|
||||
|
||||
Contract (gateway/platforms/webhook.py): the payload arrives on stdin as JSON.
|
||||
STDOUT IS A PROTOCOL CHANNEL, not a log:
|
||||
|
||||
- exactly "[SILENT]" -> delivery ignored, no agent run, no tokens spent
|
||||
- a JSON object -> REPLACES the payload used by the prompt template
|
||||
- any other text -> delivery is ALLOWED THROUGH and the text is attached
|
||||
as script_output
|
||||
|
||||
That last case is why every diagnostic here goes to stderr. A stray print()
|
||||
would not drop an event, it would let one through.
|
||||
|
||||
Empty stdout, a nonzero exit, a missing script, or a timeout also count as
|
||||
"ignored", so this script fails CLOSED: if it breaks, nothing reaches the
|
||||
agent rather than everything. That is the right direction for a loop guard,
|
||||
but it does mean a syntax error silently disables the whole integration --
|
||||
run the test file next to this one after editing.
|
||||
|
||||
Two jobs:
|
||||
|
||||
1. Filter. Drop the deliveries that must never wake the agent -- above all
|
||||
luna's own comments, which would otherwise loop forever: the prompt tells
|
||||
her to reply on the PR, and her reply is itself a pull_request_comment.
|
||||
2. Normalise. Guarantee changes.body.from always exists, so the prompt's
|
||||
{changes.body.from} renders as empty rather than as an unfilled
|
||||
placeholder on "created" events, where Gitea omits `changes` entirely.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Comment authors whose comments must never wake the agent. luna is the agent
|
||||
# herself (loop guard). Add "ci-bot" here if CI ever starts commenting on PRs
|
||||
# and you do not want her reacting to build output.
|
||||
IGNORED_AUTHORS = {"luna"}
|
||||
|
||||
# Gitea's HookIssueCommentAction values are created / edited / deleted.
|
||||
# "deleted" is dropped: the payload still carries the comment body, so letting
|
||||
# it through would have her act on a request that was explicitly withdrawn.
|
||||
ALLOWED_ACTIONS = {"created", "edited"}
|
||||
|
||||
|
||||
def ignore(reason: str) -> None:
|
||||
print(f"gitea-pr-comment-filter: ignoring delivery: {reason}", file=sys.stderr)
|
||||
print("[SILENT]")
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
payload = json.loads(sys.stdin.read())
|
||||
except (ValueError, OSError) as exc:
|
||||
ignore(f"unparseable payload: {exc}")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
ignore("payload is not a JSON object")
|
||||
|
||||
comment = payload.get("comment") or {}
|
||||
issue = payload.get("issue") or {}
|
||||
action = (payload.get("action") or "").strip().lower()
|
||||
author = ((comment.get("user") or {}).get("login") or "").strip()
|
||||
|
||||
if action not in ALLOWED_ACTIONS:
|
||||
ignore(f"action={action or '<missing>'}")
|
||||
|
||||
if author.lower() in IGNORED_AUTHORS:
|
||||
ignore(f"author={author} is the agent itself (loop guard)")
|
||||
|
||||
# Belt and braces: the route already filters to pull_request_comment, but
|
||||
# if that filter is ever loosened this keeps issue comments out. Only
|
||||
# enforced when the key is actually present.
|
||||
if "is_pull" in payload and not payload.get("is_pull"):
|
||||
ignore("not a pull request comment (is_pull=false)")
|
||||
|
||||
body = (comment.get("body") or "").strip()
|
||||
if not body:
|
||||
ignore("empty comment body")
|
||||
|
||||
# Gitea omits `changes` on created events and populates changes.body.from
|
||||
# with the pre-edit text on edits. Normalise it to a plain string so the
|
||||
# prompt template always resolves, and drop no-op edits (a label or
|
||||
# attachment change can fire "edited" without touching the body).
|
||||
changes = payload.get("changes") or {}
|
||||
previous = ((changes.get("body") or {}).get("from") or "") if isinstance(changes, dict) else ""
|
||||
if action == "edited":
|
||||
if previous.strip() == body:
|
||||
ignore("edited but comment body is unchanged")
|
||||
if not previous.strip():
|
||||
print(
|
||||
"gitea-pr-comment-filter: edited delivery carries no previous body; "
|
||||
"passing through so the agent can reconcile from the PR thread",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
payload["changes"] = {"body": {"from": previous}}
|
||||
|
||||
print(
|
||||
"gitea-pr-comment-filter: allowing comment id=%s action=%s author=%s pr=%s"
|
||||
% (comment.get("id"), action, author, issue.get("number")),
|
||||
file=sys.stderr,
|
||||
)
|
||||
json.dump(payload, sys.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -90,6 +90,19 @@ let
|
||||
# is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data.
|
||||
giteaHost = "git.mgaction.town";
|
||||
|
||||
# luna's webhook filter, mounted READ-ONLY below. It lives in the nix store
|
||||
# rather than being written into hermesHome because hermesHome IS
|
||||
# HERMES_WRITE_SAFE_ROOT: a filter dropped there is a loop guard sitting
|
||||
# inside the writable root of the agent it constrains, and she could edit
|
||||
# it back out. Deleting it would fail closed (Hermes treats a missing
|
||||
# script as "ignore"), but rewriting it to always-allow would silently
|
||||
# restore the reply loop. Read-only from the store makes that impossible
|
||||
# and keeps the guard versioned in git — same reasoning as the git/tea
|
||||
# binaries mounted below.
|
||||
prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" (
|
||||
builtins.readFile ./gitea-pr-comment-filter.py
|
||||
);
|
||||
|
||||
# hermesHome as the CONTAINER sees it (the bind mount below). Anything
|
||||
# written host-side that gets READ back inside the container must use this
|
||||
# prefix, not hermesHome — see the credential.helper below, which was
|
||||
@@ -147,6 +160,11 @@ in
|
||||
script = ''
|
||||
mkdir -p ${hermesHome}
|
||||
mkdir -p ${dropboxDir}
|
||||
# Parent for the read-only filter bind-mounted at
|
||||
# /opt/data/scripts/gitea-pr-comment-filter.py. /opt/data is itself a
|
||||
# bind mount of hermesHome, so this directory has to exist HOST-side
|
||||
# before podman can mount a file inside it.
|
||||
mkdir -p ${hermesHome}/scripts
|
||||
|
||||
export HOME=${hermesHome}
|
||||
export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig
|
||||
@@ -187,6 +205,12 @@ in
|
||||
chown ${hermesUid}:${hermesGid} \
|
||||
${hermesHome}/.gitconfig \
|
||||
${hermesHome}/.git-credentials
|
||||
# Same cont-init caveat as the files above: the directory is created
|
||||
# here as root, and Hermes reads its scripts as uid ${hermesUid}. The
|
||||
# mounted filter itself is world-readable 0444 from the store, so only
|
||||
# the directory needs handing over.
|
||||
chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts
|
||||
|
||||
if [ -d ${hermesHome}/.config ]; then
|
||||
chown ${hermesUid}:${hermesGid} ${hermesHome}/.config
|
||||
fi
|
||||
@@ -216,6 +240,10 @@ in
|
||||
# is read-only content-addressed build output, not a source of
|
||||
# secrets, so mounting the whole thing read-only costs nothing beyond
|
||||
# the two specific binaries actually being reachable.
|
||||
# Read-only: see prCommentFilter above. Hermes resolves route scripts
|
||||
# under ~/.hermes/scripts, which is /opt/data/scripts in here.
|
||||
"${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro"
|
||||
|
||||
"/nix/store:/nix/store:ro"
|
||||
"${pkgs.git}/bin/git:/usr/local/bin/git:ro"
|
||||
"${pkgs.tea}/bin/tea:/usr/local/bin/tea:ro"
|
||||
|
||||
@@ -75,6 +75,14 @@ in
|
||||
# That only works because the relay supplies X-GitHub-Event — see the
|
||||
# header comment above.
|
||||
#
|
||||
# --script does the selection that MUST NOT be retunable at runtime.
|
||||
# hosts/mars/gitea-pr-comment-filter.py drops luna's own comments before
|
||||
# any LLM call, which is what stops the reply loop: the prompt tells her to
|
||||
# answer on the PR, and her answer is itself a pull_request_comment. It is
|
||||
# bind-mounted read-only from the nix store (see hosts/mars/hermes-agent.nix)
|
||||
# so the agent cannot edit its own guard out. Hermes resolves the name
|
||||
# relative to ~/.hermes/scripts, hence the bare filename here.
|
||||
#
|
||||
# The secret is read from the CONTAINER's environment ($GITEA_HERMES_
|
||||
# WEBHOOK_SECRET, injected via sops.templates."hermes-agent.env"), which is
|
||||
# why hosts/mars/secrets.nix restarts podman-hermes-agent BEFORE this unit
|
||||
@@ -108,6 +116,7 @@ in
|
||||
hermes webhook subscribe gitea-events \
|
||||
--secret "$GITEA_HERMES_WEBHOOK_SECRET" \
|
||||
--description "Forward authenticated Gitea events to L.U.N.A." \
|
||||
--script gitea-pr-comment-filter.py \
|
||||
--deliver telegram --deliver-chat-id "15151223"
|
||||
'
|
||||
'';
|
||||
|
||||
Reference in New Issue
Block a user