This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git


The following commit(s) were added to refs/heads/main by this push:
     new 4729df76 refactor(agent-guard): move domain guards to skill ownership; 
keep only universal git guards bundled (#495)
4729df76 is described below

commit 4729df7686e40bca776763ad26838aaab56277aa
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Jun 11 18:03:55 2026 +0200

    refactor(agent-guard): move domain guards to skill ownership; keep only 
universal git guards bundled (#495)
    
    Realises the "each skill owns its deterministic guards" model on top of the
    agent-guard discovery mechanism (#494):
    
    - agent-guard now bundles only the two universal git hygiene guards
      (commit-trailer, empty-rebase). The engine, GuardContext API, and guards.d
      discovery are unchanged.
    - The PR-triage guards (mention, mark-ready) move to
      skills/pr-management-triage/guards/, and the public-PR security-language 
guard
      to skills/security-issue-fix/guards/ — each an import-free guard(ctx) file
      discovered at runtime; no settings.json re-wiring to add or remove one.
    - Setup collects every skills/*/guards/*.py (alongside the engine's bundled
      guards.d) into the adopter .claude/hooks/guards.d/ and the user-scope
      ~/.claude/scripts/guards.d/ — adopt/upgrade/verify + isolated-setup
      install/update updated; docs in secure-agent-setup.md + the tool README's
      Contributing guards contract.
    
    Tests split: test_guards.py covers the engine + bundled guards + discovery;
    test_skill_guards.py exercises the relocated guards end-to-end through the 
real
    skills/*/guards dirs via STEWARD_GUARD_DIRS (50 tests; ruff/mypy clean,
    validator + workspace-members green).
    
    Generated-by: Claude Code (Opus 4.8 1M context)
---
 docs/setup/secure-agent-setup.md                   |  24 +-
 skills/pr-management-triage/guards/mark_ready.py   |  67 +++++
 skills/pr-management-triage/guards/mention.py      |  82 ++++++
 .../security-issue-fix/guards/security_language.py |  81 ++++++
 skills/setup-isolated-setup-install/SKILL.md       |  10 +-
 skills/setup-isolated-setup-update/SKILL.md        |  16 +-
 skills/setup/adopt.md                              |  16 +-
 skills/setup/upgrade.md                            |  12 +-
 skills/setup/verify.md                             |  12 +-
 tools/agent-guard/README.md                        |  41 ++-
 tools/agent-guard/src/agent_guard/__init__.py      | 199 ++------------
 tools/agent-guard/tests/test_guards.py             | 287 ++++++---------------
 tools/agent-guard/tests/test_skill_guards.py       | 164 ++++++++++++
 13 files changed, 583 insertions(+), 428 deletions(-)

diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index a441cecc..22ff2e2e 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -944,23 +944,24 @@ A `PreToolUse` hook that, unlike the bypass-visibility 
hook above,
 **blocks** (not just annotates) a small set of `gh`/`git` commands
 that would violate a hard framework rule — protections that must not
 depend on the model remembering a `SKILL.md` instruction. The engine
-and its bundled guards live in
-[`tools/agent-guard`](../../tools/agent-guard/README.md):
+lives in [`tools/agent-guard`](../../tools/agent-guard/README.md) and
+ships two **bundled** (universal `git` hygiene) guards:
 
-- **mention** — never `@`-ping anyone but the PR/issue author in an
-  author-directed `gh pr comment` / `gh issue comment`, and never
-  `@`-mention anyone in a `gh pr edit --body` (the silent "fold"
-  channel).
 - **commit-trailer** — never let a `git commit` carry a
   `Co-Authored-By:` trailer (use `Generated-by:`).
-- **mark-ready** — never add `ready for maintainer review` while the
-  PR head SHA has GitHub Actions runs awaiting approval.
-- **security-language** — never put a CVE id / security-fix language
-  in a public `gh pr create|edit` title or body.
 - **empty-rebase** — never force-push a branch with no commits over
   its base (an empty push to a PR head auto-closes it and revokes
   write).
 
+The domain-specific guards are **owned by the skills that need them**
+and discovered the same way (below) — `skills/pr-management-triage/guards/`
+ships **mention** (never `@`-ping a non-author in an author-directed
+`gh pr comment`/`gh issue comment`; never `@`-mention anyone in a
+`gh pr edit --body` fold) and **mark-ready** (never add `ready for
+maintainer review` while CI awaits approval); 
`skills/security-issue-fix/guards/`
+ships **security-language** (no CVE / security-fix wording in a public
+`gh pr create|edit` title/body).
+
 Each guard is overridable per command by a visible inline env
 assignment (`STEWARD_ALLOW_MENTIONS=1 gh pr comment …`, etc.) or
 disabled wholesale with `STEWARD_GUARD_OFF=1` — the deny message
@@ -987,8 +988,11 @@ skill-contributed guard activates on the next 
`/magpie-setup` /
 mkdir -p ~/.claude/scripts/guards.d
 cp /path/to/airflow-steward/tools/agent-guard/src/agent_guard/__init__.py \
     ~/.claude/scripts/agent-guard.py
+# Bundled (universal) guards…
 cp /path/to/airflow-steward/tools/agent-guard/src/agent_guard/guards.d/*.py \
     ~/.claude/scripts/guards.d/
+# …plus every skill-owned guard (mention, mark-ready, security-language, …)
+cp /path/to/airflow-steward/skills/*/guards/*.py ~/.claude/scripts/guards.d/ 
2>/dev/null || true
 chmod +x ~/.claude/scripts/agent-guard.py
 ```
 
diff --git a/skills/pr-management-triage/guards/mark_ready.py 
b/skills/pr-management-triage/guards/mark_ready.py
new file mode 100644
index 00000000..fb09f56a
--- /dev/null
+++ b/skills/pr-management-triage/guards/mark_ready.py
@@ -0,0 +1,67 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""pr-management-triage mark-ready guard (skill-contributed).
+
+Deterministically enforces Golden rule 1b: never add the "ready for maintainer
+review" label while the PR head SHA still has GitHub Actions runs awaiting
+approval (the real CI has not run yet). Fail-open if the authoritative lookup
+cannot be made — never block a legitimate label on a transient error.
+Discovered by the agent-guard PreToolUse dispatcher; import-free (uses 
``ctx``).
+"""
+
+TRIGGERS = ["gh"]
+
+
+def guard(ctx):
+    if ctx.gh_subcommand() != ("pr", "edit"):
+        return None
+    label = ctx.opt("", "--add-label")
+    ready = ctx.ready_label
+    if not label or label.strip().lower() != ready.strip().lower():
+        return None
+    if ctx.override("STEWARD_ALLOW_MARK_READY"):
+        return None
+
+    target = ctx.positional_after("edit")
+    if not target:
+        return None  # fail-open: cannot identify the PR.
+    repo = ctx.opt("-R", "--repo")
+    head = ctx.run(
+        ["gh", "pr", "view", target, *ctx.repo_flag(), "--json", "headRefOid", 
"--jq", ".headRefOid"]
+    )
+    if not repo:
+        repo = ctx.run(["gh", "repo", "view", "--json", "nameWithOwner", 
"--jq", ".nameWithOwner"])
+    if not head or not repo:
+        return None  # fail-open: cannot run the authoritative check.
+    pending = ctx.run(
+        [
+            "gh",
+            "api",
+            f"repos/{repo}/actions/runs?head_sha={head}&per_page=20",
+            "--jq",
+            '[.workflow_runs[] | select(.conclusion == "action_required")] | 
length',
+        ]
+    )
+    if pending and pending.isdigit() and int(pending) > 0:
+        return (
+            f"agent-guard[mark-ready]: PR has {pending} GitHub Actions run(s) 
awaiting "
+            f"approval at head {head[:7]}; adding '{ready}' now is premature 
(Golden rule "
+            "1b) — the real CI has not run. Approve/await the workflow first. 
Override: "
+            "STEWARD_ALLOW_MARK_READY=1."
+        )
+    return None
diff --git a/skills/pr-management-triage/guards/mention.py 
b/skills/pr-management-triage/guards/mention.py
new file mode 100644
index 00000000..d389d3c6
--- /dev/null
+++ b/skills/pr-management-triage/guards/mention.py
@@ -0,0 +1,82 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""pr-management-triage mention guard (skill-contributed).
+
+Deterministically enforces the denoise rule (Golden rule 11): author-directed
+feedback never @-pings a maintainer, and the silent PR-body "fold" channel 
never
+@-mentions anyone. Discovered by the agent-guard PreToolUse dispatcher from a
+guards.d directory — see tools/agent-guard for the engine and the GuardContext
+API. Import-free: everything comes from ``ctx``.
+"""
+
+TRIGGERS = ["gh"]
+
+
+def guard(ctx):
+    sub = ctx.gh_subcommand()
+    if sub is None:
+        return None
+    group, name = sub
+    is_pr_body_edit = (
+        group == "pr"
+        and name == "edit"
+        and (ctx.opt("-b", "--body") is not None or ctx.opt("-F", 
"--body-file") is not None)
+    )
+    is_comment = (group == "pr" and name == "comment") or (group == "issue" 
and name == "comment")
+    if not (is_pr_body_edit or is_comment):
+        return None
+
+    mentions = ctx.mentions(ctx.gh_body(read_files=True))
+    if not mentions:
+        return None
+    if ctx.override("STEWARD_ALLOW_MENTIONS"):
+        return None
+
+    if is_pr_body_edit:
+        return (
+            "agent-guard[mention]: a `gh pr edit --body` (the silent 
PR-description "
+            f"'fold' channel) must not @-mention anyone — found 
{sorted(set(mentions))}. "
+            "Editing a PR body should never ping; reference logins as 
backticked "
+            "`login`, not @login. Override (rare): prefix 
STEWARD_ALLOW_MENTIONS=1."
+        )
+
+    # Comment channel: only the PR/issue author may be @-mentioned.
+    target = ctx.positional_after(name)
+    view = "pr" if group == "pr" else "issue"
+    author = None
+    if target:
+        author = ctx.run(
+            ["gh", view, "view", target, *ctx.repo_flag(), "--json", "author", 
"--jq", ".author.login"]
+        )
+    if not author:
+        return (
+            "agent-guard[mention]: this author-directed comment @-mentions "
+            f"{sorted(set(mentions))} but the PR/issue author could not be 
verified, "
+            "so the guard cannot confirm none of them are maintainers. Re-run 
once the "
+            "author is known, drop the @-mentions (use backticked `login`), or 
override "
+            "with STEWARD_ALLOW_MENTIONS=1 if the ping is intentional."
+        )
+    offenders = sorted({m for m in mentions if m != author.lower()})
+    if offenders:
+        return (
+            "agent-guard[mention]: an author-directed comment may only 
@-mention the "
+            f"author (`{author}`); refusing to ping {offenders}. Reference 
other people "
+            "as backticked `login` (no @) so they are not notified, or 
override with "
+            "STEWARD_ALLOW_MENTIONS=1 for a deliberate ping."
+        )
+    return None
diff --git a/skills/security-issue-fix/guards/security_language.py 
b/skills/security-issue-fix/guards/security_language.py
new file mode 100644
index 00000000..e0c8326b
--- /dev/null
+++ b/skills/security-issue-fix/guards/security_language.py
@@ -0,0 +1,81 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""security-issue-fix public-PR scrubbing guard (skill-contributed).
+
+Deterministically blocks a CVE id or security-fix language in a PUBLIC
+`gh pr create` / `gh pr edit` title or body — per the ASF process, the security
+nature of a fix must not appear in public content before the CVE is announced.
+
+Scoped to PR create/edit (NOT comments) on purpose, so it does not collide with
+the pr-management-triage `security_language_signal` warning comment, which
+deliberately quotes the matched text back to the contributor. Discovered by the
+agent-guard PreToolUse dispatcher; uses only ``ctx`` + the stdlib.
+"""
+
+import re
+
+TRIGGERS = ["gh"]
+
+CVE_RE = re.compile(r"\bCVE-\d{4}-\d{3,}\b", re.IGNORECASE)
+
+# Curated subset of the canonical list in tools/skill-and-tool-validator
+# (security_pattern check) — kept narrow to limit false positives.
+SECURITY_KEYWORDS = (
+    "sql injection",
+    "xss",
+    "csrf",
+    "ssrf",
+    "remote code execution",
+    "arbitrary code execution",
+    "path traversal",
+    "directory traversal",
+    "privilege escalation",
+    "auth bypass",
+    "authentication bypass",
+    "buffer overflow",
+    "heap overflow",
+    "use-after-free",
+    "security vulnerability",
+    "security fix",
+    "exploitable",
+)
+
+
+def guard(ctx):
+    if ctx.gh_subcommand() not in (("pr", "create"), ("pr", "edit")):
+        return None
+    text = ctx.gh_body(include_title=True, read_files=True)
+    if not text:
+        return None
+    if ctx.override("STEWARD_ALLOW_SECURITY_LANG"):
+        return None
+    lowered = text.lower()
+    hits = []
+    cve = CVE_RE.search(text)
+    if cve:
+        hits.append(cve.group(0))
+    hits.extend(kw for kw in SECURITY_KEYWORDS if kw in lowered)
+    if hits:
+        return (
+            "agent-guard[security-language]: this public PR title/body 
contains "
+            f"security-fix language {sorted(set(hits))}. Per the ASF process, 
the "
+            "security nature of a fix must not appear in public content before 
the CVE "
+            "is announced — neutralise the wording. If disclosure is already 
public, "
+            "override with STEWARD_ALLOW_SECURITY_LANG=1."
+        )
+    return None
diff --git a/skills/setup-isolated-setup-install/SKILL.md 
b/skills/setup-isolated-setup-install/SKILL.md
index 6a7718f8..d7d52027 100644
--- a/skills/setup-isolated-setup-install/SKILL.md
+++ b/skills/setup-isolated-setup-install/SKILL.md
@@ -101,9 +101,13 @@ Drift severity:
   `Bash`, command running the user-scope `~/.claude/scripts/agent-guard.py`)
   — the deterministic guard from
   [`tools/agent-guard`](../../tools/agent-guard/README.md). Install
-  the script + its `guards.d` alongside the other user-scope
-  scripts (Step P), wire the `PreToolUse` entry once, and preserve
-  any pre-existing `hooks` entries.
+  the script as `~/.claude/scripts/agent-guard.py` and populate
+  `~/.claude/scripts/guards.d/` from both the engine's bundled
+  `guards.d/*.py` and every skill-owned `skills/*/guards/*.py`
+  (so skill-owned guards like `mention` / `mark-ready` are active),
+  alongside the other user-scope scripts (Step P); wire the
+  `PreToolUse` entry once, and preserve any pre-existing `hooks`
+  entries.
 - **Stop on the first failure.** If a step fails (manifest read
   fails, framework path wrong, an existing file conflicts in a way
   the user has not yet decided about), stop and report. Do not
diff --git a/skills/setup-isolated-setup-update/SKILL.md 
b/skills/setup-isolated-setup-update/SKILL.md
index d1e91955..a478cd84 100644
--- a/skills/setup-isolated-setup-update/SKILL.md
+++ b/skills/setup-isolated-setup-update/SKILL.md
@@ -157,13 +157,15 @@ Walk each:
    Also diff the agent-guard hook the same way:
    `~/.claude/scripts/agent-guard.py` against the framework's
    `tools/agent-guard/src/agent_guard/__init__.py`, and the
-   `~/.claude/scripts/guards.d/` directory against the bundled
-   `tools/agent-guard/src/agent_guard/guards.d/` (extra
-   skill-contributed `*.py` are expected; flag only missing
-   bundled guards or stale copies). A new bundled guard appearing
-   in the framework but absent from the user's `guards.d` is the
-   most common drift once the hook is wired — re-syncing `guards.d`
-   activates it with **no `settings.json` change**.
+   `~/.claude/scripts/guards.d/` directory against the union of the
+   engine's bundled `tools/agent-guard/src/agent_guard/guards.d/`
+   **and** every skill-owned `skills/*/guards/*.py` (extra
+   locally-added `*.py` are expected; flag only missing
+   framework/skill guards or stale copies). A new skill guard (or a
+   skill newly adding one) appearing in the framework but absent
+   from the user's `guards.d` is the most common drift once the hook
+   is wired — re-syncing `guards.d` activates it with **no
+   `settings.json` change**.
 4. **Settings.json shape drift.** Diff the user's project
    `.claude/settings.json` against the framework's dogfooded
    one — the framework occasionally adds new `denyRead` paths
diff --git a/skills/setup/adopt.md b/skills/setup/adopt.md
index f96e9bf5..22eb0264 100644
--- a/skills/setup/adopt.md
+++ b/skills/setup/adopt.md
@@ -1092,11 +1092,17 @@ Four passes, in this order:
    - Copy the single self-contained script
      `tools/agent-guard/src/agent_guard/__init__.py` (from the
      snapshot) to `<repo-root>/.claude/hooks/agent-guard.py`, and
-     mirror the bundled `tools/agent-guard/src/agent_guard/guards.d/`
-     into `<repo-root>/.claude/hooks/guards.d/`. The dispatcher
-     auto-discovers guards from the `guards.d` sibling of the
-     script — that is how a skill contributes a guard without any
-     re-wiring (see the tool README).
+     populate `<repo-root>/.claude/hooks/guards.d/` from **two**
+     snapshot sources: the engine's bundled
+     `tools/agent-guard/src/agent_guard/guards.d/*.py`, **and every
+     skill-owned guard** — `skills/*/guards/*.py` (e.g. the
+     `pr-management-triage` `mention` + `mark-ready` guards, the
+     `security-issue-fix` `security-language` guard). Collecting all
+     of them into the single `guards.d` is what lets each skill own
+     its own deterministic guard while the hook is wired only once.
+     The dispatcher auto-discovers every `*.py` in the `guards.d`
+     sibling of the script — adding a skill (or a skill adding a
+     guard) needs no re-wiring, only this re-sync (see the tool README).
    - **Wire the hook once** in `.claude/settings.json` under
      `hooks.PreToolUse` (matcher `Bash`). Because the committed
      `.claude/settings.json` is agent-edit-denied, **surface the
diff --git a/skills/setup/upgrade.md b/skills/setup/upgrade.md
index 9a29cd3f..205f52a5 100644
--- a/skills/setup/upgrade.md
+++ b/skills/setup/upgrade.md
@@ -386,12 +386,14 @@ rather than pulls in via symlink. Examples:
   hook installed during adoption).
 - `<repo-root>/.claude/hooks/agent-guard.py` and the
   `<repo-root>/.claude/hooks/guards.d/` directory (the
-  deterministic `PreToolUse` guard dispatcher and its bundled
-  guards — see [`adopt.md` Step 
12](adopt.md#step-12--post-install-sync--worktree-propagation--sandbox-allowlist--sanity-check)
+  deterministic `PreToolUse` guard dispatcher and its guards — see
+  [`adopt.md` Step 
12](adopt.md#step-12--post-install-sync--worktree-propagation--sandbox-allowlist--sanity-check)
   and [`tools/agent-guard`](../../tools/agent-guard/README.md)).
-  Re-syncing `guards.d` is also how newly-added skill-contributed
-  guards reach an already-adopted repo — the `settings.json`
-  `hooks.PreToolUse` wiring is unchanged.
+  `guards.d/` is populated from **both** the engine's bundled
+  `guards.d/*.py` **and** every skill-owned `skills/*/guards/*.py`
+  in the snapshot. Re-syncing it is how a new skill — or a skill
+  that newly adds a guard — reaches an already-adopted repo; the
+  `settings.json` `hooks.PreToolUse` wiring is unchanged.
 - Any future hook or local config the framework adds.
 
 These can drift independently of the snapshot — an
diff --git a/skills/setup/verify.md b/skills/setup/verify.md
index c79383f4..b7d2bfc1 100644
--- a/skills/setup/verify.md
+++ b/skills/setup/verify.md
@@ -313,10 +313,14 @@ Three sub-checks for the deterministic guard
    `tools/agent-guard/src/agent_guard/__init__.py`.
    - ⚠ / ✗ on missing / stale — remediation is `/magpie-setup`
      (adopt or upgrade), whose sync pass re-installs it.
-2. **`guards.d` present.** `<repo-root>/.claude/hooks/guards.d/`
-   exists and its bundled guards match the snapshot. Extra
-   skill-contributed `*.py` here are expected — only flag
-   *missing bundled* guards or stale copies.
+2. **`guards.d` populated.** `<repo-root>/.claude/hooks/guards.d/`
+   exists and contains every guard the snapshot ships — the
+   engine's bundled `guards.d/*.py` **and** each skill-owned
+   `skills/*/guards/*.py` (e.g. `mention`, `mark_ready`,
+   `security_language`). Flag a *missing* expected guard or a stale
+   copy; extra locally-added `*.py` are fine. A missing skill guard
+   means that skill's deterministic protection is silently inactive
+   — remediation is `/magpie-setup` (adopt/upgrade), which re-collects.
 3. **Hook wired in settings.json.** `<repo-root>/.claude/settings.json`
    has a `hooks.PreToolUse` entry (matcher `Bash`) whose command
    runs `agent-guard.py`.
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
index 6f6e540e..eebc80bc 100644
--- a/tools/agent-guard/README.md
+++ b/tools/agent-guard/README.md
@@ -6,6 +6,7 @@
   - [Guards](#guards)
   - [Per-command overrides](#per-command-overrides)
   - [Wiring](#wiring)
+  - [Contributing guards](#contributing-guards)
   - [Tests](#tests)
 
 <!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -29,14 +30,23 @@ few milliseconds for any command that is not a guarded `gh` 
/ `git commit` /
 
 ## Guards
 
+**Bundled** (shipped with the engine — universal `git` hygiene, on for every
+project):
+
 | Guard | Blocks | Rule it enforces |
 |---|---|---|
-| `mention` | `gh pr comment` / `gh issue comment` that `@`-mentions anyone 
other than the PR/issue author; **any** `@`-mention in `gh pr edit 
--body[-file]` (the silent "fold" channel) | denoise: author-directed feedback 
never pings maintainers; body edits stay silent |
 | `commit-trailer` | `git commit` whose message contains `Co-Authored-By:` | 
AGENTS.md: agents use a `Generated-by:` trailer, never co-author |
-| `mark-ready` | adding the `ready for maintainer review` label while the PR 
head SHA has GitHub Actions runs awaiting approval | pr-management-triage 
Golden rule 1b |
-| `security-language` | a CVE id or security-fix language in a **public** `gh 
pr create` / `gh pr edit` title/body (not comments) | security-issue-fix 
public-PR scrubbing |
 | `empty-rebase` | `git push --force[-with-lease]` of a branch with 0 commits 
over its base | an empty push to a PR head auto-closes it + revokes write |
 
+**Skill-owned** (each lives in its skill's `guards/` dir, discovered the same
+way — see [Contributing guards](#contributing-guards)):
+
+| Guard | Owner skill | Blocks | Rule it enforces |
+|---|---|---|---|
+| `mention` | `pr-management-triage` | `gh pr comment` / `gh issue comment` 
that `@`-mentions anyone other than the PR/issue author; **any** `@`-mention in 
`gh pr edit --body[-file]` | denoise: author-directed feedback never pings 
maintainers; body edits stay silent |
+| `mark-ready` | `pr-management-triage` | adding `ready for maintainer review` 
while the PR head SHA has GitHub Actions runs awaiting approval | Golden rule 
1b |
+| `security-language` | `security-issue-fix` | a CVE id / security-fix 
language in a **public** `gh pr create`/`gh pr edit` title/body (not comments) 
| public-PR scrubbing |
+
 A denied command is **not** posted/run; the model is shown the reason and the
 deterministic fix (e.g. "use a backtick `` `login` `` instead of `@login`").
 
@@ -83,6 +93,31 @@ secure setup (`~/.claude/scripts/agent-guard.py`); 
`/magpie-setup upgrade`,
 `verify`, and the `setup-isolated-setup-install` / `…-update` skills keep it 
and
 the settings.json entry in sync. See those skills for the exact steps.
 
+## Contributing guards
+
+The hook is **wired once**. Beyond the two bundled guards, additional guards 
are
+discovered at runtime from every `*.py` in a `guards.d` directory — the
+`guards.d` sibling of the running script, plus any directory listed in
+`$STEWARD_GUARD_DIRS` (colon-separated). **No `settings.json` change is needed 
to
+add a guard.**
+
+A skill owns its guards by shipping them under `skills/<skill>/guards/*.py`;
+`/magpie-setup` collects every `skills/*/guards/*.py` (plus the engine's 
bundled
+`guards.d`) into the adopter's `.claude/hooks/guards.d/` (and the user-scope
+`~/.claude/scripts/guards.d/`). A guard file is **import-free** — it defines:
+
+- `TRIGGERS` — optional list of command families to pre-filter on (`"gh"`,
+  `"git:commit"`, `"git:push"`, …); omit to run on every guarded command.
+- `guard(ctx)` — returns a deny-reason string to block, or `None` to allow.
+  `ctx` is the `GuardContext`: `ctx.argv`, `ctx.raw`, `ctx.override(*names)`,
+  `ctx.gh_subcommand()`, `ctx.opt(short, long)`, `ctx.gh_body(...)`,
+  `ctx.mentions(text)`, `ctx.positional_after(token)`, `ctx.repo_flag()`,
+  `ctx.run(args)`, `ctx.ready_label`.
+
+A guard file that fails to import is skipped (a broken contribution never 
breaks
+the shell). See `guards.d/no_verify_commit.py` for the template, and
+`skills/pr-management-triage/guards/` for real examples.
+
 ## Tests
 
 ```bash
diff --git a/tools/agent-guard/src/agent_guard/__init__.py 
b/tools/agent-guard/src/agent_guard/__init__.py
index 6c4c66cb..aa9c15fa 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -19,21 +19,21 @@
 
 Reads a Claude Code ``PreToolUse`` hook event on stdin, inspects the ``Bash``
 command, and **denies** the ones that would violate a hard framework rule
-that should never depend on the model remembering a SKILL.md instruction:
+that should never depend on the model remembering a SKILL.md instruction.
 
-1. **mention** — never ``@``-ping anyone other than the PR/issue author in an
-   author-directed comment, and never ``@``-mention anyone in a PR-body edit
-   (the silent "fold" channel). Mirrors the denoise change (PR #491).
-2. **commit-trailer** — never let a ``git commit`` carry a ``Co-Authored-By:``
+The engine ships two **bundled** guards — the universal ``git`` hygiene rules
+that apply to every project:
+
+1. **commit-trailer** — never let a ``git commit`` carry a ``Co-Authored-By:``
    trailer (AGENTS.md: agents use ``Generated-by:``, never co-author).
-3. **mark-ready** — never add the "ready for maintainer review" label while the
-   PR head SHA still has GitHub Actions runs awaiting approval
-   (pr-management-triage Golden rule 1b).
-4. **security-language** — never put a CVE id or security-fix language in a
-   public PR title/body (security-issue-fix public-PR scrubbing rule).
-5. **empty-rebase** — never force-push a branch that has no commits over its
+2. **empty-rebase** — never force-push a branch that has no commits over its
    base (an empty push to a PR head auto-closes the PR and revokes write).
 
+Domain-specific guards are **owned and contributed by the skills that need
+them** via the discovery mechanism below — e.g. the ``mention`` and
+``mark-ready`` guards live in ``skills/pr-management-triage/guards/`` and the
+``security-language`` guard in ``skills/security-issue-fix/guards/``.
+
 The hook fires on *every* ``Bash`` call, so this module is **stdlib-only** and
 meant to be invoked directly as ``python3 .../agent_guard/__init__.py`` — never
 through ``uv run`` — and returns in a few milliseconds for any command that is
@@ -44,8 +44,8 @@ maintainer can consciously proceed 
(``STEWARD_ALLOW_MENTIONS=1 gh pr comment …
 or disable the whole dispatcher (``STEWARD_GUARD_OFF=1``). Overrides are read
 from the command string itself (and from the hook's own environment).
 
-**Contributing guards.** The five above are *bundled* guards. Any skill can add
-its own deterministic guard **without re-wiring the hook**: drop an import-free
+**Contributing guards.** Beyond the two bundled guards, any skill adds its own
+deterministic guard **without re-wiring the hook**: drop an import-free
 ``*.py`` file into a discovered ``guards.d`` directory (the ``guards.d`` 
sibling
 of this script, plus any dir in ``$STEWARD_GUARD_DIRS``) that defines a
 module-level ``guard(ctx)`` returning a deny string or ``None`` — see
@@ -87,32 +87,6 @@ MENTION_RE = 
re.compile(r"(?<![\w.@])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:/[A-Z
 _FENCED_RE = re.compile(r"```.*?```", re.DOTALL)
 _INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
 
-CVE_RE = re.compile(r"\bCVE-\d{4}-\d{3,}\b", re.IGNORECASE)
-
-# Security-fix language that must not appear in a PUBLIC pr title/body before a
-# CVE is announced. A curated subset of the canonical list in
-# tools/skill-and-tool-validator (security_pattern check) — kept deliberately
-# narrow to limit false positives on the PR-create/edit surface.
-SECURITY_KEYWORDS = (
-    "sql injection",
-    "xss",
-    "csrf",
-    "ssrf",
-    "remote code execution",
-    "arbitrary code execution",
-    "path traversal",
-    "directory traversal",
-    "privilege escalation",
-    "auth bypass",
-    "authentication bypass",
-    "buffer overflow",
-    "heap overflow",
-    "use-after-free",
-    "security vulnerability",
-    "security fix",
-    "exploitable",
-)
-
 GUARD_TIMEOUT = 10  # seconds for any subprocess (gh / git) a guard shells out 
to.
 
 
@@ -272,68 +246,6 @@ def _repo_flag(argv: list[str]) -> list[str]:
 # --------------------------------------------------------------------------- #
 
 
-def guard_mention(seg: Segment, cwd: str | None) -> str | None:
-    sub = gh_subcommand(seg.argv)
-    if sub is None:
-        return None
-    group, name = sub
-    is_pr_body_edit = (
-        group == "pr"
-        and name == "edit"
-        and (
-            _opt_value(seg.argv, "-b", "--body") is not None
-            or _opt_value(seg.argv, "-F", "--body-file") is not None
-        )
-    )
-    is_comment = (group == "pr" and name == "comment") or (group == "issue" 
and name == "comment")
-    if not (is_pr_body_edit or is_comment):
-        return None
-
-    body = gh_body_text(seg.argv, include_title=False, read_files=True)
-    mentions = find_mentions(body)
-    if not mentions:
-        return None
-    if seg.override("STEWARD_ALLOW_MENTIONS"):
-        return None
-
-    if is_pr_body_edit:
-        return (
-            "agent-guard[mention]: a `gh pr edit --body` (the silent 
PR-description "
-            f"'fold' channel) must not @-mention anyone — found 
{sorted(set(mentions))}. "
-            "Editing a PR body should never ping; reference logins as 
backticked "
-            "`login`, not @login. Override (rare): prefix 
STEWARD_ALLOW_MENTIONS=1."
-        )
-
-    # Comment channel: only the PR/issue author may be @-mentioned.
-    sub_index = seg.argv.index(name)
-    target = _positional_target(seg.argv, sub_index)
-    view = "pr" if group == "pr" else "issue"
-    author = None
-    if target:
-        author = _run(
-            ["gh", view, "view", target, *_repo_flag(seg.argv), "--json", 
"author", "--jq", ".author.login"],
-            cwd=cwd,
-        )
-    if not author:
-        return (
-            "agent-guard[mention]: this author-directed comment @-mentions "
-            f"{sorted(set(mentions))} but the PR/issue author could not be 
verified, "
-            "so the guard cannot confirm none of them are maintainers. Re-run 
once the "
-            "author is known, drop the @-mentions (use backticked `login`), or 
override "
-            "with STEWARD_ALLOW_MENTIONS=1 if the ping is intentional."
-        )
-    author_l = author.lower()
-    offenders = sorted({m for m in mentions if m != author_l})
-    if offenders:
-        return (
-            "agent-guard[mention]: an author-directed comment may only 
@-mention the "
-            f"author (`{author}`); refusing to ping {offenders}. Reference 
other people "
-            "as backticked `login` (no @) so they are not notified, or 
override with "
-            "STEWARD_ALLOW_MENTIONS=1 for a deliberate ping."
-        )
-    return None
-
-
 def guard_commit_trailer(seg: Segment, cwd: str | None) -> str | None:
     if seg.argv[:2] != ["git", "commit"]:
         return None
@@ -349,79 +261,6 @@ def guard_commit_trailer(seg: Segment, cwd: str | None) -> 
str | None:
     )
 
 
-def guard_mark_ready(seg: Segment, cwd: str | None) -> str | None:
-    sub = gh_subcommand(seg.argv)
-    if sub != ("pr", "edit"):
-        return None
-    label = _opt_value(seg.argv, "", "--add-label")
-    ready = os.environ.get(READY_LABEL_ENV, DEFAULT_READY_LABEL)
-    if not label or label.strip().lower() != ready.strip().lower():
-        return None
-    if seg.override("STEWARD_ALLOW_MARK_READY"):
-        return None
-
-    sub_index = seg.argv.index("edit")
-    target = _positional_target(seg.argv, sub_index)
-    if not target:
-        return None  # fail-open: cannot identify the PR.
-    repo = _opt_value(seg.argv, "-R", "--repo")
-    head = _run(
-        ["gh", "pr", "view", target, *_repo_flag(seg.argv), "--json", 
"headRefOid", "--jq", ".headRefOid"],
-        cwd=cwd,
-    )
-    if not repo:
-        repo = _run(
-            ["gh", "repo", "view", "--json", "nameWithOwner", "--jq", 
".nameWithOwner"],
-            cwd=cwd,
-        )
-    if not head or not repo:
-        return None  # fail-open: cannot run the authoritative check.
-    pending = _run(
-        [
-            "gh",
-            "api",
-            f"repos/{repo}/actions/runs?head_sha={head}&per_page=20",
-            "--jq",
-            '[.workflow_runs[] | select(.conclusion == "action_required")] | 
length',
-        ],
-        cwd=cwd,
-    )
-    if pending and pending.isdigit() and int(pending) > 0:
-        return (
-            f"agent-guard[mark-ready]: PR has {pending} GitHub Actions run(s) 
awaiting "
-            f"approval at head {head[:7]}; adding '{ready}' now is premature 
(Golden rule "
-            "1b) — the real CI has not run. Approve/await the workflow first. 
Override: "
-            "STEWARD_ALLOW_MARK_READY=1."
-        )
-    return None
-
-
-def guard_security_language(seg: Segment, cwd: str | None) -> str | None:
-    sub = gh_subcommand(seg.argv)
-    if sub not in (("pr", "create"), ("pr", "edit")):
-        return None
-    text = gh_body_text(seg.argv, include_title=True, read_files=True)
-    if not text:
-        return None
-    if seg.override("STEWARD_ALLOW_SECURITY_LANG"):
-        return None
-    lowered = text.lower()
-    hits: list[str] = []
-    cve = CVE_RE.search(text)
-    if cve:
-        hits.append(cve.group(0))
-    hits.extend(kw for kw in SECURITY_KEYWORDS if kw in lowered)
-    if hits:
-        return (
-            "agent-guard[security-language]: this public PR title/body 
contains "
-            f"security-fix language {sorted(set(hits))}. Per the ASF process, 
the "
-            "security nature of a fix must not appear in public content before 
the CVE "
-            "is announced — neutralise the wording. If disclosure is already 
public, "
-            "override with STEWARD_ALLOW_SECURITY_LANG=1."
-        )
-    return None
-
-
 def guard_empty_rebase(seg: Segment, cwd: str | None) -> str | None:
     if seg.argv[:2] != ["git", "push"]:
         return None
@@ -462,14 +301,16 @@ def guard_empty_rebase(seg: Segment, cwd: str | None) -> 
str | None:
     return None
 
 
-# The framework's bundled guards. Skills contribute MORE without editing this
-# file or re-wiring the hook — see "Contributing guards" below.
+# The framework's bundled guards — the universal `git` hygiene rules that apply
+# to every project regardless of which skills are installed. Domain-specific
+# guards are owned and contributed by the skills that need them (e.g. the
+# mention + mark-ready guards live in `skills/pr-management-triage/guards/`, 
the
+# security-language guard in `skills/security-issue-fix/guards/`); they are
+# discovered at runtime from `guards.d` without editing this file or re-wiring
+# the hook — see "Contributing guards" below.
 BUILTIN_GUARDS: tuple[Callable[[Segment, str | None], str | None], ...] = (
     guard_commit_trailer,
     guard_empty_rebase,
-    guard_security_language,
-    guard_mention,
-    guard_mark_ready,
 )
 
 # Only commands in these families are inspected; everything else takes the
diff --git a/tools/agent-guard/tests/test_guards.py 
b/tools/agent-guard/tests/test_guards.py
index 66df27b1..c8051b30 100644
--- a/tools/agent-guard/tests/test_guards.py
+++ b/tools/agent-guard/tests/test_guards.py
@@ -15,6 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
+"""Engine + bundled-guard tests. The relocated skill-owned guards (mention,
+mark-ready, security-language) are tested in test_skill_guards.py through the
+same discovery path a real adopter uses."""
+
 import json
 
 import pytest
@@ -26,12 +30,13 @@ import agent_guard
 def _clear_env(monkeypatch):
     for name in (
         "STEWARD_GUARD_OFF",
-        "STEWARD_ALLOW_MENTIONS",
+        "STEWARD_GUARD_DIRS",
         "STEWARD_ALLOW_COAUTHOR",
+        "STEWARD_ALLOW_EMPTY_PUSH",
+        "STEWARD_ALLOW_NO_VERIFY",
+        "STEWARD_ALLOW_MENTIONS",
         "STEWARD_ALLOW_MARK_READY",
         "STEWARD_ALLOW_SECURITY_LANG",
-        "STEWARD_ALLOW_EMPTY_PUSH",
-        "STEWARD_READY_LABEL",
     ):
         monkeypatch.delenv(name, raising=False)
 
@@ -46,7 +51,7 @@ def fake_run(handler):
 
 
 # --------------------------------------------------------------------------- #
-# find_mentions / strip_code
+# find_mentions / strip_code (shared helper, stays in the engine)
 # --------------------------------------------------------------------------- #
 
 
@@ -67,78 +72,7 @@ def test_find_mentions(text, expected):
 
 
 # --------------------------------------------------------------------------- #
-# mention guard
-# --------------------------------------------------------------------------- #
-
-
-def test_mention_author_allowed(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
-    assert dispatch('gh pr comment 5 --body "@alice thanks for the fix"') is 
None
-
-
-def test_mention_non_author_denied(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
-    reason = dispatch('gh pr comment 5 --body "@bob please review"')
-    assert reason and "bob" in reason and "mention" in reason
-
-
-def test_mention_mixed_denies_only_non_author(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
-    reason = dispatch('gh pr comment 5 --body "@alice @bob done"')
-    assert reason and "bob" in reason and "alice" not in 
reason.split("refusing")[-1]
-
-
-def test_mention_issue_comment(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
-    assert dispatch('gh issue comment 9 --body "@bob ping"') is not None
-
-
-def test_fold_any_mention_denied():
-    # No author lookup needed for a pr-edit body.
-    reason = dispatch('gh pr edit 5 --body "@alice heads up"')
-    assert reason and "fold" in reason
-
-
-def test_fold_clean_allowed():
-    assert dispatch('gh pr edit 5 --body "rebased onto main, fixed 
conflicts"') is None
-
-
-def test_fold_backtick_login_allowed():
-    assert dispatch('gh pr edit 5 --body "see `alice` review"') is None
-
-
-def test_mention_body_file(monkeypatch, tmp_path):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
-    body = tmp_path / "b.md"
-    body.write_text("@bob please look", encoding="utf-8")
-    assert dispatch(f"gh pr comment 5 --body-file {body}") is not None
-
-
-def test_mention_no_mention_no_lookup(monkeypatch):
-    def boom(args):
-        raise AssertionError("should not shell out when no mention present")
-
-    monkeypatch.setattr(agent_guard, "_run", fake_run(boom))
-    assert dispatch('gh pr comment 5 --body "thanks, looks good"') is None
-
-
-def test_mention_author_unresolved_fails_closed(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: None))
-    reason = dispatch('gh pr comment 5 --body "@bob hi"')
-    assert reason and "could not be verified" in reason
-
-
-def test_mention_override(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
-    assert dispatch('STEWARD_ALLOW_MENTIONS=1 gh pr comment 5 --body "@bob 
ping"') is None
-
-
-def test_global_off(monkeypatch):
-    assert dispatch('STEWARD_GUARD_OFF=1 gh pr edit 5 --body "@alice @bob"') 
is None
-
-
-# --------------------------------------------------------------------------- #
-# commit-trailer guard
+# commit-trailer guard (bundled)
 # --------------------------------------------------------------------------- #
 
 
@@ -160,81 +94,7 @@ def test_commit_coauthor_override():
 
 
 # --------------------------------------------------------------------------- #
-# mark-ready guard
-# --------------------------------------------------------------------------- #
-
-
-def _mark_ready_handler(pending):
-    def handler(args):
-        if "headRefOid" in args:
-            return "deadbeefcafebabe1234"
-        if any("actions/runs" in a for a in args):
-            return pending
-        return None
-
-    return handler
-
-
-def test_mark_ready_pending_denied(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", 
fake_run(_mark_ready_handler("2")))
-    reason = dispatch('gh pr edit 5 --repo o/r --add-label "ready for 
maintainer review"')
-    assert reason and "awaiting approval" in reason
-
-
-def test_mark_ready_clean_allowed(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", 
fake_run(_mark_ready_handler("0")))
-    assert dispatch('gh pr edit 5 --repo o/r --add-label "ready for maintainer 
review"') is None
-
-
-def test_mark_ready_other_label_allowed(monkeypatch):
-    def boom(args):
-        raise AssertionError("no lookup for an unrelated label")
-
-    monkeypatch.setattr(agent_guard, "_run", fake_run(boom))
-    assert dispatch('gh pr edit 5 --repo o/r --add-label "area:scheduler"') is 
None
-
-
-def test_mark_ready_failopen_when_head_unknown(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: None))
-    assert dispatch('gh pr edit 5 --repo o/r --add-label "ready for maintainer 
review"') is None
-
-
-def test_mark_ready_override(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", 
fake_run(_mark_ready_handler("3")))
-    cmd = 'STEWARD_ALLOW_MARK_READY=1 gh pr edit 5 --repo o/r --add-label 
"ready for maintainer review"'
-    assert dispatch(cmd) is None
-
-
-# --------------------------------------------------------------------------- #
-# security-language guard
-# --------------------------------------------------------------------------- #
-
-
-def test_security_cve_in_pr_create_denied():
-    reason = dispatch('gh pr create --title "Fix CVE-2026-1234" --body 
"patch"')
-    assert reason and "security" in reason.lower()
-
-
-def test_security_keyword_in_pr_body_denied():
-    assert dispatch('gh pr create --title "fix" --body "patches a SQL 
injection"') is not None
-
-
-def test_security_clean_pr_create_allowed():
-    assert dispatch('gh pr create --title "Add retry policy" --body 
"implements AIP-105"') is None
-
-
-def test_security_language_in_comment_allowed():
-    # Comments are NOT in scope (avoids colliding with the triage security 
warning).
-    assert dispatch('gh pr comment 5 --body "this looks like a SQL injection 
risk"') is None
-
-
-def test_security_override():
-    cmd = 'STEWARD_ALLOW_SECURITY_LANG=1 gh pr create --title "Fix 
CVE-2026-1234" --body "x"'
-    assert dispatch(cmd) is None
-
-
-# --------------------------------------------------------------------------- #
-# empty-rebase guard
+# empty-rebase guard (bundled)
 # --------------------------------------------------------------------------- #
 
 
@@ -282,6 +142,57 @@ def test_empty_rebase_override(monkeypatch):
     assert dispatch("STEWARD_ALLOW_EMPTY_PUSH=1 git push --force origin b:b") 
is None
 
 
+# --------------------------------------------------------------------------- #
+# bundled example contributed guard (guards.d/no_verify_commit.py)
+# --------------------------------------------------------------------------- #
+
+
+def test_bundled_no_verify_guard_discovered():
+    reason = dispatch('git commit -m "x" --no-verify')
+    assert reason and "no-verify" in reason
+
+
+def test_no_verify_override():
+    assert dispatch('STEWARD_ALLOW_NO_VERIFY=1 git commit -n -m "x"') is None
+
+
+def test_plain_commit_not_blocked_by_no_verify_guard():
+    assert dispatch('git commit -m "ordinary commit"') is None
+
+
+# --------------------------------------------------------------------------- #
+# contributed-guard discovery
+# --------------------------------------------------------------------------- #
+
+
+def test_contributed_guard_from_env_dir(monkeypatch, tmp_path):
+    gdir = tmp_path / "guards.d"
+    gdir.mkdir()
+    (gdir / "block_merge_admin.py").write_text(
+        'TRIGGERS = ["gh"]\n'
+        "def guard(ctx):\n"
+        "    sub = ctx.gh_subcommand()\n"
+        "    if sub == ('pr', 'merge') and any(t in ('--admin',) for t in 
ctx.argv):\n"
+        "        if ctx.override('STEWARD_ALLOW_ADMIN_MERGE'):\n"
+        "            return None\n"
+        "        return 'contributed[admin-merge]: refusing gh pr merge 
--admin'\n"
+        "    return None\n",
+        encoding="utf-8",
+    )
+    monkeypatch.setenv("STEWARD_GUARD_DIRS", str(gdir))
+    assert dispatch("gh pr merge 5 --admin") is not None
+    assert dispatch("STEWARD_ALLOW_ADMIN_MERGE=1 gh pr merge 5 --admin") is 
None
+    assert dispatch("gh pr view 5 --json title") is None
+
+
+def test_broken_contributed_guard_fails_open(monkeypatch, tmp_path):
+    gdir = tmp_path / "guards.d"
+    gdir.mkdir()
+    (gdir / "broken.py").write_text("this is not valid python !!!", 
encoding="utf-8")
+    monkeypatch.setenv("STEWARD_GUARD_DIRS", str(gdir))
+    assert dispatch("gh pr view 5") is None
+
+
 # --------------------------------------------------------------------------- #
 # dispatch / fast path / compound commands
 # --------------------------------------------------------------------------- #
@@ -302,27 +213,32 @@ def test_fast_path_allows(command):
     assert dispatch(command) is None
 
 
-def test_compound_command_guarded(monkeypatch):
-    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
-    assert dispatch('cd /tmp && gh pr comment 5 --body "@bob hi"') is not None
+def test_compound_command_guarded():
+    # The commit-trailer guard (bundled) fires on the second segment.
+    assert dispatch('cd /tmp && git commit -m "x\nCo-Authored-By: a"') is not 
None
 
 
 def test_malformed_command_allows():
-    # Unbalanced quotes -> cannot tokenise -> allow (never break the shell).
     assert dispatch('gh pr comment 5 --body "oops') is None
 
 
+def test_global_off(monkeypatch):
+    assert dispatch('STEWARD_GUARD_OFF=1 git commit -m "x\nCo-Authored-By: 
a"') is None
+
+
 # --------------------------------------------------------------------------- #
 # main() stdin contract
 # --------------------------------------------------------------------------- #
 
 
 def test_main_emits_deny(monkeypatch, capsys):
-    event = {"tool_name": "Bash", "tool_input": {"command": 'gh pr edit 5 
--body "@bob"'}}
+    event = {
+        "tool_name": "Bash",
+        "tool_input": {"command": 'git commit -m "x\nCo-Authored-By: a"'},
+    }
     monkeypatch.setattr("sys.stdin", _Stdin(json.dumps(event)))
     rc = agent_guard.main()
-    out = capsys.readouterr().out
-    payload = json.loads(out)
+    payload = json.loads(capsys.readouterr().out)
     assert rc == 0
     assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
 
@@ -330,8 +246,7 @@ def test_main_emits_deny(monkeypatch, capsys):
 def test_main_allows_non_bash(monkeypatch, capsys):
     event = {"tool_name": "Read", "tool_input": {"file_path": "x"}}
     monkeypatch.setattr("sys.stdin", _Stdin(json.dumps(event)))
-    rc = agent_guard.main()
-    assert rc == 0
+    assert agent_guard.main() == 0
     assert capsys.readouterr().out == ""
 
 
@@ -341,58 +256,6 @@ def test_main_allows_malformed_stdin(monkeypatch, capsys):
     assert capsys.readouterr().out == ""
 
 
-# --------------------------------------------------------------------------- #
-# contributed-guard discovery
-# --------------------------------------------------------------------------- #
-
-
-def test_bundled_no_verify_guard_discovered():
-    # The bundled example guard in src/agent_guard/guards.d is auto-discovered
-    # from the default sibling dir — no env needed.
-    reason = dispatch('git commit -m "x" --no-verify')
-    assert reason and "no-verify" in reason
-
-
-def test_no_verify_override():
-    assert dispatch('STEWARD_ALLOW_NO_VERIFY=1 git commit -n -m "x"') is None
-
-
-def test_plain_commit_not_blocked_by_no_verify_guard():
-    assert dispatch('git commit -m "ordinary commit"') is None
-
-
-def test_contributed_guard_from_env_dir(monkeypatch, tmp_path):
-    # A skill contributes a guard by dropping a file in a guards.d dir; 
pointing
-    # STEWARD_GUARD_DIRS at it wires it in with no change to settings.json.
-    gdir = tmp_path / "guards.d"
-    gdir.mkdir()
-    (gdir / "block_merge_admin.py").write_text(
-        'TRIGGERS = ["gh"]\n'
-        "def guard(ctx):\n"
-        "    sub = ctx.gh_subcommand()\n"
-        "    if sub == ('pr', 'merge') and any(t in ('--admin',) for t in 
ctx.argv):\n"
-        "        if ctx.override('STEWARD_ALLOW_ADMIN_MERGE'):\n"
-        "            return None\n"
-        "        return 'contributed[admin-merge]: refusing gh pr merge 
--admin'\n"
-        "    return None\n",
-        encoding="utf-8",
-    )
-    monkeypatch.setenv("STEWARD_GUARD_DIRS", str(gdir))
-    assert dispatch("gh pr merge 5 --admin") is not None
-    assert dispatch("STEWARD_ALLOW_ADMIN_MERGE=1 gh pr merge 5 --admin") is 
None
-    # Unrelated gh command is unaffected by the contributed guard.
-    assert dispatch("gh pr view 5 --json title") is None
-
-
-def test_broken_contributed_guard_fails_open(monkeypatch, tmp_path):
-    gdir = tmp_path / "guards.d"
-    gdir.mkdir()
-    (gdir / "broken.py").write_text("this is not valid python !!!", 
encoding="utf-8")
-    monkeypatch.setenv("STEWARD_GUARD_DIRS", str(gdir))
-    # A guard file that cannot import must never break the shell.
-    assert dispatch("gh pr view 5") is None
-
-
 # Convenience wrapper so each test reads cleanly.
 def dispatch(command):
     return agent_guard.dispatch(command, cwd=None)
diff --git a/tools/agent-guard/tests/test_skill_guards.py 
b/tools/agent-guard/tests/test_skill_guards.py
new file mode 100644
index 00000000..da25c289
--- /dev/null
+++ b/tools/agent-guard/tests/test_skill_guards.py
@@ -0,0 +1,164 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for the skill-owned guards, exercised end-to-end through the 
agent-guard
+discovery path — exactly how an adopter runs them. ``STEWARD_GUARD_DIRS`` 
points
+the dispatcher at the real ``skills/<skill>/guards`` directories in this repo, 
so
+these tests fail if a guard file is moved, renamed, or broken."""
+
+import os
+from pathlib import Path
+
+import pytest
+
+import agent_guard
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+TRIAGE_GUARDS = REPO_ROOT / "skills" / "pr-management-triage" / "guards"
+SECURITY_GUARDS = REPO_ROOT / "skills" / "security-issue-fix" / "guards"
+
+
[email protected](autouse=True)
+def _wire_skill_guards(monkeypatch):
+    for name in (
+        "STEWARD_GUARD_OFF",
+        "STEWARD_ALLOW_MENTIONS",
+        "STEWARD_ALLOW_MARK_READY",
+        "STEWARD_ALLOW_SECURITY_LANG",
+    ):
+        monkeypatch.delenv(name, raising=False)
+    monkeypatch.setenv("STEWARD_GUARD_DIRS", 
f"{TRIAGE_GUARDS}{os.pathsep}{SECURITY_GUARDS}")
+
+
+def fake_run(handler):
+    def _stub(args, cwd=None):
+        return handler(args)
+
+    return _stub
+
+
+def dispatch(command):
+    return agent_guard.dispatch(command, cwd=None)
+
+
+def test_guard_files_exist():
+    assert (TRIAGE_GUARDS / "mention.py").is_file()
+    assert (TRIAGE_GUARDS / "mark_ready.py").is_file()
+    assert (SECURITY_GUARDS / "security_language.py").is_file()
+
+
+# --- mention guard (skill-owned) ------------------------------------------- #
+
+
+def test_mention_author_allowed(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
+    assert dispatch('gh pr comment 5 --body "@alice thanks"') is None
+
+
+def test_mention_non_author_denied(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
+    reason = dispatch('gh pr comment 5 --body "@bob please review"')
+    assert reason and "bob" in reason and "mention" in reason
+
+
+def test_fold_any_mention_denied():
+    reason = dispatch('gh pr edit 5 --body "@alice heads up"')
+    assert reason and "fold" in reason
+
+
+def test_fold_clean_allowed():
+    assert dispatch('gh pr edit 5 --body "rebased onto main, fixed 
conflicts"') is None
+
+
+def test_mention_body_file(monkeypatch, tmp_path):
+    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
+    body = tmp_path / "b.md"
+    body.write_text("@bob please look", encoding="utf-8")
+    assert dispatch(f"gh pr comment 5 --body-file {body}") is not None
+
+
+def test_mention_author_unresolved_fails_closed(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: None))
+    reason = dispatch('gh pr comment 5 --body "@bob hi"')
+    assert reason and "could not be verified" in reason
+
+
+def test_mention_override(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: "alice"))
+    assert dispatch('STEWARD_ALLOW_MENTIONS=1 gh pr comment 5 --body "@bob 
ping"') is None
+
+
+# --- mark-ready guard (skill-owned) ---------------------------------------- #
+
+
+def _mark_ready_handler(pending):
+    def handler(args):
+        if "headRefOid" in args:
+            return "deadbeefcafebabe1234"
+        if any("actions/runs" in a for a in args):
+            return pending
+        return None
+
+    return handler
+
+
+def test_mark_ready_pending_denied(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", 
fake_run(_mark_ready_handler("2")))
+    reason = dispatch('gh pr edit 5 --repo o/r --add-label "ready for 
maintainer review"')
+    assert reason and "awaiting approval" in reason
+
+
+def test_mark_ready_clean_allowed(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", 
fake_run(_mark_ready_handler("0")))
+    assert dispatch('gh pr edit 5 --repo o/r --add-label "ready for maintainer 
review"') is None
+
+
+def test_mark_ready_other_label_allowed(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: None))
+    assert dispatch('gh pr edit 5 --repo o/r --add-label "area:scheduler"') is 
None
+
+
+def test_mark_ready_override(monkeypatch):
+    monkeypatch.setattr(agent_guard, "_run", 
fake_run(_mark_ready_handler("3")))
+    cmd = 'STEWARD_ALLOW_MARK_READY=1 gh pr edit 5 --repo o/r --add-label 
"ready for maintainer review"'
+    assert dispatch(cmd) is None
+
+
+# --- security-language guard (skill-owned) --------------------------------- #
+
+
+def test_security_cve_in_pr_create_denied():
+    reason = dispatch('gh pr create --title "Fix CVE-2026-1234" --body 
"patch"')
+    assert reason and "security" in reason.lower()
+
+
+def test_security_keyword_in_pr_body_denied():
+    assert dispatch('gh pr create --title "fix" --body "patches a SQL 
injection"') is not None
+
+
+def test_security_clean_pr_create_allowed():
+    assert dispatch('gh pr create --title "Add retry policy" --body 
"implements an AIP"') is None
+
+
+def test_security_language_in_comment_allowed():
+    # Comments are out of scope (avoids colliding with the triage security 
warning).
+    assert dispatch('gh pr comment 5 --body "this looks like a SQL injection 
risk"') is None
+
+
+def test_security_override():
+    cmd = 'STEWARD_ALLOW_SECURITY_LANG=1 gh pr create --title "Fix 
CVE-2026-1234" --body "x"'
+    assert dispatch(cmd) is None

Reply via email to