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