From 9ba48088c6f73d20003e6bcafe94f02b55999281 Mon Sep 17 00:00:00 2001 From: Lio Lunesu Date: Wed, 5 Aug 2026 18:28:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20declared=20watch=20policy=20?= =?UTF-8?q?=E2=80=94=20rules=20instead=20of=20muting=20humans=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standing watch spawned a session for every non-CI event from an unmuted sender, and ignore_senders was the only lever — sender-granular, so stopping a human's close/merge echoes meant muting the human, which also swallowed the new issues the watch exists to catch. This box mutes its owner today for exactly that reason, and echo spawns eat hook- session slots that drop at the cap (#170). local-webhook 0.11.0 (local-channels#15, spun out of local-channels#14 after #13 settled that the upstream repo is mechanism only) adds per- subscription when/drop payload predicates: an entry carrying them owns its whole spawn policy — the failures-only CI brake steps aside, sender muting moves inside the rules where it can be scoped to echo-shaped events, and every declined event is logged. This PR is the policy half: - webhook.watchPolicy option: declarative rules per topic, enforced onto the matching filter.dispatch.json entry by an ExecStartPre on each user's receiver daemon. Upgrade-only: the module governs watches that sessions created, it never creates one, so the default is inert on a box without such a watch. The declaration replaces the managed fields (when/drop/ignoreSenders/note) wholesale; runtime fields (ttl, timestamps) stay the entry's own. - Default policy for the box's own-repos watch (github:defangdevs/*): spawn for issues/PRs opened or reopened by anyone but the box's own login, and for terminal CI failures whoever triggered the run; closes, merges, pushes, green and in-flight CI spawn nothing. The ignoreSenders on the governed entry is CLEARED — deploying this un-mutes the human owner, whose new issues then get triaged again. - Pin bump to local-channels 74a99dd (0.11.0), superseding the pin half of #196 (its green-run VM test remains valid: rule-less watches keep the 0.10.1 failures-only brake). - CLI + built-in AGENTS.md documentation for --when/--drop and the governed-entry rule (edit the NixOS config, not the entry). - VM test: a governed watch gets its rules and loses its ad-hoc sender mute on daemon restart while a rule-less sibling entry is untouched; a close echo is declined (and the decline logged); an outsider's opened issue still spawns. Verification: - nix run .#assemble; checks.aarch64-linux module-generated-up-to-date, multi-user, module-single-file, webhook-route, download-route all pass natively. - tests/webhook.nix passes nix-instantiate --parse and the ty/ruff testscript gates (the VM test itself is x86_64-only; not run here). - The reconcile jq was exercised against a fixture mirroring this box's live filter.dispatch.json (entry with ignoreSenders ["defangdevs","lionello"]): rules applied, mute cleared, timestamps/ ttl preserved, sibling entries untouched. The governed entry was then routed through the pinned webhook.py 0.11.0 directly: human's opened issue spawns, box echo / PR close / merge push / green run do not, box's own failing run does. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SDHb5tyTqZwzKM6Qdr8cxv --- modules/agent-box.nix | 179 ++++++++++++++++++++++++++++++++++++++- modules/agent-box.nix.in | 179 ++++++++++++++++++++++++++++++++++++++- tests/webhook.nix | 107 +++++++++++++++++++++++ 3 files changed, 461 insertions(+), 4 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 4cab25a..3d27f7f 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -133,6 +133,16 @@ let (`agent-box-session rm NAME`) when done — if stale `hook-*` sessions pile up, clean them the same way. + A watch can carry payload rules (`--when` / `--drop`, JSON predicates over + payload paths) that replace the failure-only default with its own spawn + policy — see `agent-box-webhook --help`. The box's standing watch on its + own repos is governed that way from the NixOS config + (`services.agent-box.webhook.watchPolicy`), re-applied whenever the + receiver daemon starts: don't hand-edit a governed entry (its note says + so), and don't mute a HUMAN's login on a watch to silence close/merge + echoes — the rules already drop those while keeping the person's new + issues and PRs spawning. + One-time per box, so deliveries can arrive at all: agent-box-webhook setup # prints the endpoint URL + a fresh HMAC secret @@ -898,6 +908,7 @@ EOF usage: agent-box-webhook subscribe TOPIC [--note TEXT] [--ttl HOURS] [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... + [--when JSON] [--drop JSON] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -931,6 +942,17 @@ EOF --ignore-sender LOGIN mutes echoes of that sender's own comments and pushes ("@self" is $LOCAL_WEBHOOK_SELF); CI-outcome events are delivered anyway. + --when / --drop attach payload rules to the subscription: deliver (or + spawn) ONLY events matching --when, never those matching --drop. Rules are + JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} + leaves. A subscription with rules sets its own policy: the failure-only CI + brake steps aside for it, and sender muting belongs INSIDE the rules + ({"path": "sender.login", "notIn": [...]}) rather than --ignore-sender. + NOTE: rules on the box's own standing watches may be managed declaratively + (services.agent-box.webhook.watchPolicy) and re-applied whenever the + receiver daemon starts — such entries say so in their note; change the + NixOS config, not the entry. + One-time per box, to make deliveries possible at all: agent-box-webhook setup # mints the HMAC secret, prints URL + secret then register that URL + secret in the sender (GitHub: repo Settings -> @@ -1154,6 +1176,60 @@ agent-box-webhook ls).}" $PROMPT" ''; + # Declared standing-watch policy (webhook.watchPolicy), rendered once into + # the store and enforced onto each user's filter.dispatch.json by the + # receiver daemon's ExecStartPre. Upgrade-only by design: the module governs + # rules on watches sessions created, it never creates a watch — so the + # default (the box's own-repos policy) is inert anywhere no such watch + # exists. Runs as the user; a policy failure must never keep the receiver + # down, so every branch here exits 0. + webhookWatchPolicyFile = pkgs.writeText "agent-box-webhook-watch-policy.json" + (builtins.toJSON cfg.webhook.watchPolicy); + webhookPolicyApply = pkgs.writeShellScript "agent-box-webhook-policy-apply" '' + set -eu + JQ=${pkgs.jq}/bin/jq + FILE="''${LOCAL_WEBHOOK_STATE_DIR:?}/filter.dispatch.json" + POLICY=${webhookWatchPolicyFile} + if [ ! -s "$FILE" ]; then + echo "agent-box-webhook-policy: no dispatch file yet; nothing to govern" >&2 + exit 0 + fi + # For every entry whose topic is declared: the declaration REPLACES the + # managed fields (when/drop/ignoreSenders/note) wholesale — partial merges + # would let config and state drift apart. Runtime fields (ttlHours, + # renewOnEvent, timestamps) stay the entry's own. Bare-string topics + # normalize to the object form webhook.py itself writes. + tmp="$FILE.policy.$$" + if "$JQ" --slurpfile pol "$POLICY" ' + $pol[0] as $p + | .topics = [ (.topics // [])[] + | (if type == "string" then {topic: .} else . end) + | (if type == "object" then (.topic // "") else "" end) as $t + | if $t != "" and ($p | has($t)) then + $p[$t] as $r + | del(.when, .drop, .ignoreSenders) + | (if $r.when != null then .when = $r.when else . end) + | (if $r.drop != null then .drop = $r.drop else . end) + | (if (($r.ignoreSenders // []) | length) > 0 then .ignoreSenders = $r.ignoreSenders else . end) + | (if $r.note != null then .note = $r.note else . end) + else . end ] + ' "$FILE" > "$tmp"; then + ${pkgs.coreutils}/bin/mv -f "$tmp" "$FILE" + # Say which watches are governed — and when none is, that the policy is + # idle: a rule that silently applied to nothing reads like a rule that + # worked (#170). + "$JQ" -r --slurpfile pol "$POLICY" ' + [ .topics[] | if type == "string" then . else (.topic // "") end ] as $subs + | [ ($pol[0] | keys[]) | select(. as $t | $subs | index($t)) ] + | if length > 0 then "agent-box-webhook-policy: enforced declared rules on " + join(", ") + else "agent-box-webhook-policy: no declared topic is subscribed; policy idle" end + ' "$FILE" >&2 + else + ${pkgs.coreutils}/bin/rm -f "$tmp" + echo "agent-box-webhook-policy: could not rewrite $FILE; receiver starts with it unchanged" >&2 + fi + ''; + # One session = one agent CLI in one tmux session. These options are the # FIRST-BOOT SEED only (see users..sessions); at runtime the same # fields live as JSON in ~/.config/agent-box/sessions.json. @@ -2103,7 +2179,7 @@ in }; rev = lib.mkOption { type = lib.types.str; - default = "efa253166074789ece9709e4a1e3a4be58801166"; + default = "74a99dd86804c9a53bd4bfdf66f9815d9df13e92"; description = '' Pinned local-channels commit whose local-webhook/webhook.py the receiver daemon and the agent-box-webhook CLI run. Claude sessions @@ -2117,9 +2193,103 @@ in # builtins.fetchurl hash of local-webhook/webhook.py at `rev`: # nix-prefetch-url https://raw.githubusercontent.com///local-webhook/webhook.py # then `nix hash convert --hash-algo sha256 --to sri `. - default = "sha256-qHVF398YAwiIqbYG2/HY+hVzY4HF4saiasdZHAXngIM="; + default = "sha256-wzbIZoC4681JARV/xJ5DLd4kYjnOomN2Z4FUGlwvk+E="; description = "builtins.fetchurl hash of the pinned local-webhook/webhook.py."; }; + watchPolicy = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule { + options = { + when = lib.mkOption { + type = lib.types.nullOr lib.types.attrs; + default = null; + description = '' + Payload predicate the watch accepts events by (local-webhook + 0.11.0 `when`): `{any/all: [...]}` over + `{path, in/notIn: [values]}` leaves. null declares no `when`. + ''; + }; + drop = lib.mkOption { + type = lib.types.nullOr lib.types.attrs; + default = null; + description = '' + Payload predicate the watch refuses events by (`drop`, + evaluated first, wins over `when`). null declares no `drop`. + ''; + }; + ignoreSenders = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Sender mute for the entry; the default (empty) CLEARS any + ad-hoc mute on the governed entry. With rules, sender policy + belongs inside the predicate ({path: "sender.login", + notIn: [...]}), where it can be scoped to echo-shaped events + instead of silencing a person's every event — the blunt trade + that once muted a human's new issues to stop their close + buttons (#197). + ''; + }; + note = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Replacement note for the entry; null keeps whatever note it has."; + }; + }; + }); + # The default governs the standing watch agents are told to keep on + # the box's own repos, IF one exists. Current policy: spawn a triage + # session for issues/PRs entering the queue (opened/reopened) unless + # the box's own login created them, and for terminal CI failures + # whoever triggered the run; everything else — closes, merges, pushes, + # green or in-flight CI — is not worth a session. On a box whose + # sessions never subscribed a defangdevs watch this default matches no + # entry and does nothing; set it to { } to opt out entirely. + default = { + "github:defangdevs/*" = { + note = "standing watch: unowned defangdevs activity (new issues, outside PRs, failing CI) — " + + "rules managed by services.agent-box.webhook.watchPolicy and re-applied when the " + + "receiver daemon starts, so edit the NixOS config, not this entry"; + when = + let + # local-webhook's CI_FAILURE_STATES, spelled out here on + # purpose: which conclusions deserve a session is THIS repo's + # policy now (local-channels#13 keeps mechanism upstream). + ciFailure = [ "failure" "timed_out" "action_required" "startup_failure" "stale" "error" ]; + in + { + any = [ + { + all = [ + { path = "action"; "in" = [ "opened" "reopened" ]; } + { path = "sender.login"; notIn = [ "defangdevs" ]; } + ]; + } + { path = "workflow_run.conclusion"; "in" = ciFailure; } + { path = "workflow_job.conclusion"; "in" = ciFailure; } + { path = "check_run.conclusion"; "in" = ciFailure; } + { path = "check_suite.conclusion"; "in" = ciFailure; } + { path = "deployment_status.state"; "in" = [ "error" "failure" ]; } + { path = "state"; "in" = [ "error" "failure" ]; } + ]; + }; + }; + }; + description = '' + Declarative policy for standing watches (#197), keyed by exact + topic. The module never creates a watch: sessions do that + (`agent-box-webhook subscribe --deliver-to subagent`). What this + option owns is the POLICY on such a watch — before each user's + receiver daemon starts, the declared when/drop/ignoreSenders/note + are enforced onto the matching entry in that user's + filter.dispatch.json (other entries and the entry's own + topic/ttl/timestamps untouched), replacing whatever ad-hoc rules a + session left there. A watch carrying when/drop sets its own spawn + policy: local-webhook's built-in failures-only CI brake steps aside + for it, and every event the rules decline is logged by the daemon, + so a deliberate drop stays distinguishable from a watch that broke + (#170). Requires local-webhook >= 0.11.0 (the webhook.rev pin). + ''; + }; }; spotInterruption = { @@ -5702,6 +5872,11 @@ in User = name; Restart = "always"; RestartSec = "5s"; + # Enforce the declared watch policy (webhook.watchPolicy) onto this + # user's filter.dispatch.json before the receiver starts routing. + # Runs as User (no "+" prefix); exits 0 on every path, because a + # policy hiccup must not take the box's one ingress down with it. + ExecStartPre = "${webhookPolicyApply}"; ExecStart = "${webhookPython} ${localWebhookScript}"; # systemd passes the ingress socket on fd 3 (LISTEN_FDS) and wires # stdin to /dev/null; RECEIVER_ONLY tolerates that (no MCP stdio). diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index dd4a8e0..2e1c2d5 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -129,6 +129,16 @@ let (`agent-box-session rm NAME`) when done — if stale `hook-*` sessions pile up, clean them the same way. + A watch can carry payload rules (`--when` / `--drop`, JSON predicates over + payload paths) that replace the failure-only default with its own spawn + policy — see `agent-box-webhook --help`. The box's standing watch on its + own repos is governed that way from the NixOS config + (`services.agent-box.webhook.watchPolicy`), re-applied whenever the + receiver daemon starts: don't hand-edit a governed entry (its note says + so), and don't mute a HUMAN's login on a watch to silence close/merge + echoes — the rules already drop those while keeping the person's new + issues and PRs spawning. + One-time per box, so deliveries can arrive at all: agent-box-webhook setup # prints the endpoint URL + a fresh HMAC secret @@ -894,6 +904,7 @@ EOF usage: agent-box-webhook subscribe TOPIC [--note TEXT] [--ttl HOURS] [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... + [--when JSON] [--drop JSON] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -927,6 +938,17 @@ EOF --ignore-sender LOGIN mutes echoes of that sender's own comments and pushes ("@self" is $LOCAL_WEBHOOK_SELF); CI-outcome events are delivered anyway. + --when / --drop attach payload rules to the subscription: deliver (or + spawn) ONLY events matching --when, never those matching --drop. Rules are + JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} + leaves. A subscription with rules sets its own policy: the failure-only CI + brake steps aside for it, and sender muting belongs INSIDE the rules + ({"path": "sender.login", "notIn": [...]}) rather than --ignore-sender. + NOTE: rules on the box's own standing watches may be managed declaratively + (services.agent-box.webhook.watchPolicy) and re-applied whenever the + receiver daemon starts — such entries say so in their note; change the + NixOS config, not the entry. + One-time per box, to make deliveries possible at all: agent-box-webhook setup # mints the HMAC secret, prints URL + secret then register that URL + secret in the sender (GitHub: repo Settings -> @@ -1150,6 +1172,60 @@ agent-box-webhook ls).}" $PROMPT" ''; + # Declared standing-watch policy (webhook.watchPolicy), rendered once into + # the store and enforced onto each user's filter.dispatch.json by the + # receiver daemon's ExecStartPre. Upgrade-only by design: the module governs + # rules on watches sessions created, it never creates a watch — so the + # default (the box's own-repos policy) is inert anywhere no such watch + # exists. Runs as the user; a policy failure must never keep the receiver + # down, so every branch here exits 0. + webhookWatchPolicyFile = pkgs.writeText "agent-box-webhook-watch-policy.json" + (builtins.toJSON cfg.webhook.watchPolicy); + webhookPolicyApply = pkgs.writeShellScript "agent-box-webhook-policy-apply" '' + set -eu + JQ=${pkgs.jq}/bin/jq + FILE="''${LOCAL_WEBHOOK_STATE_DIR:?}/filter.dispatch.json" + POLICY=${webhookWatchPolicyFile} + if [ ! -s "$FILE" ]; then + echo "agent-box-webhook-policy: no dispatch file yet; nothing to govern" >&2 + exit 0 + fi + # For every entry whose topic is declared: the declaration REPLACES the + # managed fields (when/drop/ignoreSenders/note) wholesale — partial merges + # would let config and state drift apart. Runtime fields (ttlHours, + # renewOnEvent, timestamps) stay the entry's own. Bare-string topics + # normalize to the object form webhook.py itself writes. + tmp="$FILE.policy.$$" + if "$JQ" --slurpfile pol "$POLICY" ' + $pol[0] as $p + | .topics = [ (.topics // [])[] + | (if type == "string" then {topic: .} else . end) + | (if type == "object" then (.topic // "") else "" end) as $t + | if $t != "" and ($p | has($t)) then + $p[$t] as $r + | del(.when, .drop, .ignoreSenders) + | (if $r.when != null then .when = $r.when else . end) + | (if $r.drop != null then .drop = $r.drop else . end) + | (if (($r.ignoreSenders // []) | length) > 0 then .ignoreSenders = $r.ignoreSenders else . end) + | (if $r.note != null then .note = $r.note else . end) + else . end ] + ' "$FILE" > "$tmp"; then + ${pkgs.coreutils}/bin/mv -f "$tmp" "$FILE" + # Say which watches are governed — and when none is, that the policy is + # idle: a rule that silently applied to nothing reads like a rule that + # worked (#170). + "$JQ" -r --slurpfile pol "$POLICY" ' + [ .topics[] | if type == "string" then . else (.topic // "") end ] as $subs + | [ ($pol[0] | keys[]) | select(. as $t | $subs | index($t)) ] + | if length > 0 then "agent-box-webhook-policy: enforced declared rules on " + join(", ") + else "agent-box-webhook-policy: no declared topic is subscribed; policy idle" end + ' "$FILE" >&2 + else + ${pkgs.coreutils}/bin/rm -f "$tmp" + echo "agent-box-webhook-policy: could not rewrite $FILE; receiver starts with it unchanged" >&2 + fi + ''; + # One session = one agent CLI in one tmux session. These options are the # FIRST-BOOT SEED only (see users..sessions); at runtime the same # fields live as JSON in ~/.config/agent-box/sessions.json. @@ -2099,7 +2175,7 @@ in }; rev = lib.mkOption { type = lib.types.str; - default = "efa253166074789ece9709e4a1e3a4be58801166"; + default = "74a99dd86804c9a53bd4bfdf66f9815d9df13e92"; description = '' Pinned local-channels commit whose local-webhook/webhook.py the receiver daemon and the agent-box-webhook CLI run. Claude sessions @@ -2113,9 +2189,103 @@ in # builtins.fetchurl hash of local-webhook/webhook.py at `rev`: # nix-prefetch-url https://raw.githubusercontent.com///local-webhook/webhook.py # then `nix hash convert --hash-algo sha256 --to sri `. - default = "sha256-qHVF398YAwiIqbYG2/HY+hVzY4HF4saiasdZHAXngIM="; + default = "sha256-wzbIZoC4681JARV/xJ5DLd4kYjnOomN2Z4FUGlwvk+E="; description = "builtins.fetchurl hash of the pinned local-webhook/webhook.py."; }; + watchPolicy = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule { + options = { + when = lib.mkOption { + type = lib.types.nullOr lib.types.attrs; + default = null; + description = '' + Payload predicate the watch accepts events by (local-webhook + 0.11.0 `when`): `{any/all: [...]}` over + `{path, in/notIn: [values]}` leaves. null declares no `when`. + ''; + }; + drop = lib.mkOption { + type = lib.types.nullOr lib.types.attrs; + default = null; + description = '' + Payload predicate the watch refuses events by (`drop`, + evaluated first, wins over `when`). null declares no `drop`. + ''; + }; + ignoreSenders = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Sender mute for the entry; the default (empty) CLEARS any + ad-hoc mute on the governed entry. With rules, sender policy + belongs inside the predicate ({path: "sender.login", + notIn: [...]}), where it can be scoped to echo-shaped events + instead of silencing a person's every event — the blunt trade + that once muted a human's new issues to stop their close + buttons (#197). + ''; + }; + note = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Replacement note for the entry; null keeps whatever note it has."; + }; + }; + }); + # The default governs the standing watch agents are told to keep on + # the box's own repos, IF one exists. Current policy: spawn a triage + # session for issues/PRs entering the queue (opened/reopened) unless + # the box's own login created them, and for terminal CI failures + # whoever triggered the run; everything else — closes, merges, pushes, + # green or in-flight CI — is not worth a session. On a box whose + # sessions never subscribed a defangdevs watch this default matches no + # entry and does nothing; set it to { } to opt out entirely. + default = { + "github:defangdevs/*" = { + note = "standing watch: unowned defangdevs activity (new issues, outside PRs, failing CI) — " + + "rules managed by services.agent-box.webhook.watchPolicy and re-applied when the " + + "receiver daemon starts, so edit the NixOS config, not this entry"; + when = + let + # local-webhook's CI_FAILURE_STATES, spelled out here on + # purpose: which conclusions deserve a session is THIS repo's + # policy now (local-channels#13 keeps mechanism upstream). + ciFailure = [ "failure" "timed_out" "action_required" "startup_failure" "stale" "error" ]; + in + { + any = [ + { + all = [ + { path = "action"; "in" = [ "opened" "reopened" ]; } + { path = "sender.login"; notIn = [ "defangdevs" ]; } + ]; + } + { path = "workflow_run.conclusion"; "in" = ciFailure; } + { path = "workflow_job.conclusion"; "in" = ciFailure; } + { path = "check_run.conclusion"; "in" = ciFailure; } + { path = "check_suite.conclusion"; "in" = ciFailure; } + { path = "deployment_status.state"; "in" = [ "error" "failure" ]; } + { path = "state"; "in" = [ "error" "failure" ]; } + ]; + }; + }; + }; + description = '' + Declarative policy for standing watches (#197), keyed by exact + topic. The module never creates a watch: sessions do that + (`agent-box-webhook subscribe --deliver-to subagent`). What this + option owns is the POLICY on such a watch — before each user's + receiver daemon starts, the declared when/drop/ignoreSenders/note + are enforced onto the matching entry in that user's + filter.dispatch.json (other entries and the entry's own + topic/ttl/timestamps untouched), replacing whatever ad-hoc rules a + session left there. A watch carrying when/drop sets its own spawn + policy: local-webhook's built-in failures-only CI brake steps aside + for it, and every event the rules decline is logged by the daemon, + so a deliberate drop stays distinguishable from a watch that broke + (#170). Requires local-webhook >= 0.11.0 (the webhook.rev pin). + ''; + }; }; spotInterruption = { @@ -3306,6 +3476,11 @@ in User = name; Restart = "always"; RestartSec = "5s"; + # Enforce the declared watch policy (webhook.watchPolicy) onto this + # user's filter.dispatch.json before the receiver starts routing. + # Runs as User (no "+" prefix); exits 0 on every path, because a + # policy hiccup must not take the box's one ingress down with it. + ExecStartPre = "${webhookPolicyApply}"; ExecStart = "${webhookPython} ${localWebhookScript}"; # systemd passes the ingress socket on fd 3 (LISTEN_FDS) and wires # stdin to /dev/null; RECEIVER_ONLY tolerates that (no MCP stdio). diff --git a/tests/webhook.nix b/tests/webhook.nix index 248e2b3..0069f08 100644 --- a/tests/webhook.nix +++ b/tests/webhook.nix @@ -54,6 +54,25 @@ fail2ban = false; }; # webhook.enable is deliberately NOT set: this test asserts the DEFAULT. + # Watch policy (#197) for the SECOND standing watch below. The first + # watch (defangdevs/agent-box) deliberately stays rule-less, so the + # legacy failures-only brake keeps its own coverage next to this. + webhook.watchPolicy = { + "github:defangdevs/local-channels" = { + note = "managed: rules watch (test)"; + when = { + any = [ + { + all = [ + { path = "action"; "in" = [ "opened" "reopened" ]; } + { path = "sender.login"; notIn = [ "box-bot" ]; } + ]; + } + { path = "workflow_run.conclusion"; "in" = [ "failure" ]; } + ]; + }; + }; + }; }; system.stateVersion = "25.05"; @@ -503,6 +522,94 @@ f" /home/agent/.local/state/local-webhook/filter.agent-{other}.json" ) + # --- watchPolicy: declared rules govern a standing watch (#197) --------- + # The module manages POLICY for watches sessions create, never the watches + # themselves: at daemon start an ExecStartPre enforces the declared + # when/drop/ignoreSenders/note onto the matching filter.dispatch.json + # entry. Subscribe the watch with ad-hoc state (a sender mute, a stale + # note), restart, and the declaration replaces it. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/local-channels --deliver-to subagent" + " --note 'to be governed' --ignore-sender human" + ) + machine.succeed("systemctl restart agent-box-webhook-agent.service") + machine.wait_for_unit("agent-box-webhook-agent.service") + machine.wait_until_succeeds( + "journalctl -u agent-box-webhook-agent --no-pager" + " | grep -q 'enforced declared rules on github:defangdevs/local-channels'", + timeout=30, + ) + # Rules present, the ad-hoc sender mute cleared (sender policy lives inside + # the rules — muting the human outright is the trade #197 exists to end), + # note replaced; runtime fields (pinned ttl) kept. + machine.succeed( + "jq -e '.topics[] | select(.topic == \"github:defangdevs/local-channels\")" + " | (.when.any | length == 2) and (has(\"ignoreSenders\") | not)" + " and .note == \"managed: rules watch (test)\" and .ttlHours == 0'" + " /home/agent/.local/state/local-webhook/filter.dispatch.json" + ) + # ...while the rule-less agent-box watch was left exactly alone. + machine.succeed( + "jq -e '.topics[] | select(.topic == \"github:defangdevs/agent-box\")" + " | (has(\"when\") or has(\"drop\")) | not'" + " /home/agent/.local/state/local-webhook/filter.dispatch.json" + ) + + # Behavior, end to end: a close echo is declined by the rules — logged, so + # a deliberate drop stays distinguishable from a broken watch (#170) — and + # an outsider's opened issue still spawns a triage session. + client.succeed( + "cat > /tmp/lc-closed.json <<'EOF'\n" + '{"action":"closed","pull_request":{"number":3,"title":"done",' + '"html_url":"https://box.test/pr/3"},' + '"repository":{"full_name":"defangdevs/local-channels"},"sender":{"login":"human"}}\n' + "EOF" + ) + sig_lc1 = client.succeed( + f"openssl dgst -sha256 -hmac {secret} -r /tmp/lc-closed.json | cut -d' ' -f1" + ).strip() + client.succeed( + f"{curl} -o /dev/null -w '%{{http_code}}' -X POST" + " -H 'content-type: application/json' -H 'x-github-event: pull_request'" + " -H 'x-github-delivery: test-lc-closed'" + f" -H 'x-hub-signature-256: sha256={sig_lc1}' --data-binary @/tmp/lc-closed.json" + " https://box.test/agent/webhook/github | grep -x 200" + ) + machine.wait_until_succeeds( + "journalctl -u agent-box-webhook-agent --no-pager" + " | grep -q 'not spawning for pull_request on defangdevs/local-channels'", + timeout=30, + ) + machine.fail( + "jq -e '.sessions | keys[] | select(startswith(\"hook-defangdevs-local-chan\"))'" + " /home/agent/.config/agent-box/sessions.json" + ) + client.succeed( + "cat > /tmp/lc-opened.json <<'EOF'\n" + '{"action":"opened","issue":{"number":21,"title":"found a bug",' + '"html_url":"https://box.test/issue/21"},' + '"repository":{"full_name":"defangdevs/local-channels"},"sender":{"login":"human"}}\n' + "EOF" + ) + sig_lc2 = client.succeed( + f"openssl dgst -sha256 -hmac {secret} -r /tmp/lc-opened.json | cut -d' ' -f1" + ).strip() + client.succeed( + f"{curl} -o /dev/null -w '%{{http_code}}' -X POST" + " -H 'content-type: application/json' -H 'x-github-event: issues'" + " -H 'x-github-delivery: test-lc-opened'" + f" -H 'x-hub-signature-256: sha256={sig_lc2}' --data-binary @/tmp/lc-opened.json" + " https://box.test/agent/webhook/github | grep -x 200" + ) + machine.wait_until_succeeds( + "jq -e '.sessions | keys[] | select(startswith(\"hook-defangdevs-local-chan\"))'" + " /home/agent/.config/agent-box/sessions.json", + timeout=60, + ) + # The daemon is the ingress owner and survives every delivery — the box's # endpoint must not depend on which sessions happen to be alive. machine.succeed("systemctl is-active agent-box-webhook-agent.service")