diff --git a/hosts/mars/gitea-pr-comment-filter-test.py b/hosts/mars/gitea-pr-comment-filter-test.py new file mode 100644 index 0000000..a2ead1d --- /dev/null +++ b/hosts/mars/gitea-pr-comment-filter-test.py @@ -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) diff --git a/hosts/mars/gitea-pr-comment-filter.py b/hosts/mars/gitea-pr-comment-filter.py new file mode 100644 index 0000000..cd95313 --- /dev/null +++ b/hosts/mars/gitea-pr-comment-filter.py @@ -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 ''}") + + 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() diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index f78e861..964700d 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -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" diff --git a/services/dev/gitea-hermes-webhook-relay.nix b/services/dev/gitea-hermes-webhook-relay.nix index 7e82e31..21cdc48 100644 --- a/services/dev/gitea-hermes-webhook-relay.nix +++ b/services/dev/gitea-hermes-webhook-relay.nix @@ -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" ' '';