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 1cb47814 feat(agent-guard): deterministic PreToolUse guard dispatcher
+ skill-contributable guards (#494)
1cb47814 is described below
commit 1cb4781484aac9461427cd2caa246e9ebb059982
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Jun 11 15:36:44 2026 +0200
feat(agent-guard): deterministic PreToolUse guard dispatcher +
skill-contributable guards (#494)
Adds tools/agent-guard: a stdlib-only Claude Code PreToolUse hook that
inspects every Bash command before it runs and DENIES the ones that would
break a hard framework rule — protections that must not depend on the model
remembering a SKILL.md instruction.
Five bundled guards: mention (denoise #491 — never @-ping non-authors in
author-directed comments; no @-mention in `gh pr edit --body`),
commit-trailer
(no Co-Authored-By; use Generated-by), mark-ready (Golden rule 1b — no
ready-label while CI awaits approval), security-language (no CVE/security
wording in public PR title/body), empty-rebase (no force-push of a 0-commit
branch). Each overridable per command (STEWARD_ALLOW_*); STEWARD_GUARD_OFF=1
disables all; non-gh/git commands fast-path in ~22ms.
Extensible: guards are discovered at runtime from guards.d (+
$STEWARD_GUARD_DIRS)
via a GuardContext API, so any skill contributes a guard by dropping one
import-free guard(ctx) file — the hook is wired ONCE at setup, no
settings.json
change to add a guard. Ships an example + discovery tests (54 tests,
ruff/mypy
clean, validator + workspace-members green).
Setup wiring across adopt/upgrade/verify and the isolated-setup
install/update
skills; docs in secure-agent-setup.md; deterministic-enforcement cross-refs
in
pr-management-triage Golden rules 1b/11 and the AGENTS.md commit-trailer
rule.
Generated-by: Claude Code (Opus 4.8 1M context)
---
AGENTS.md | 5 +
docs/labels-and-capabilities.md | 1 +
docs/setup/secure-agent-setup.md | 97 ++-
pyproject.toml | 1 +
skills/pr-management-triage/SKILL.md | 14 +-
skills/setup-isolated-setup-install/SKILL.md | 12 +-
skills/setup-isolated-setup-update/SKILL.md | 15 +-
skills/setup/adopt.md | 33 +
skills/setup/upgrade.md | 8 +
skills/setup/verify.md | 22 +
tools/agent-guard/README.md | 93 +++
tools/agent-guard/pyproject.toml | 78 +++
tools/agent-guard/src/agent_guard/__init__.py | 670 +++++++++++++++++++++
.../src/agent_guard/guards.d/no_verify_commit.py | 53 ++
tools/agent-guard/tests/test_guards.py | 406 +++++++++++++
uv.lock | 8 +-
16 files changed, 1509 insertions(+), 7 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index d07cddc2..0945a280 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -399,6 +399,11 @@ to a home-dir path and update the tool to read from there.
contributions. This applies without exception, including to commits
prepared by an agent on the user's behalf in this framework repository
itself. **Re-read this rule before preparing every `git commit`.**
+ When the framework's secure setup is installed, this is **also
+ enforced deterministically** by the agent-guard `PreToolUse` hook
+ (the `commit-trailer` guard), which blocks any `git commit` whose
+ message contains a `Co-Authored-By:` trailer — see
+ [`tools/agent-guard`](tools/agent-guard/README.md).
Use a `Generated-by:` trailer instead. The form is:
```text
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 30d367c8..4fa5e1ae 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -179,6 +179,7 @@ Tools under [`tools/`](../tools/). Tools with two values
(separated by
| Tool | Capability / capabilities | Role |
|---|---|---|
+| [`tools/agent-guard`](../tools/agent-guard/) | `capability:setup` |
Deterministic `PreToolUse` guard dispatcher: blocks `gh`/`git` commands that
would ping maintainers, carry a `Co-Authored-By` trailer, mark-ready
prematurely, leak security language publicly, or empty a PR via force-push.
Extensible — skills contribute guards via `guards.d` |
| [`tools/agent-isolation`](../tools/agent-isolation/) | `capability:setup` |
Secure-agent sandbox helpers |
| [`tools/apache-projects`](../tools/apache-projects/) | `capability:stats` +
`capability:intake` | ASF project-metadata substrate (`apache/comdev`
`apache-projects-mcp`); read-only `projects.apache.org/json` rosters / people /
releases. Backs `contributor-nomination` and the security roster-resolution
paths; tracked at `main`, not pinned |
| [`tools/cve-org`](../tools/cve-org/) | `capability:resolve` +
`capability:intake` | Publishes to CVE.org *(resolve)* and records the
resulting CVE state back into the tracker *(intake)* |
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index a8f28b50..a441cecc 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -25,11 +25,15 @@
- [Install (user-scope)](#install-user-scope)
- [Verify](#verify)
- [Trade-offs](#trade-offs)
+ - [Agent-guard deterministic guard
hook](#agent-guard-deterministic-guard-hook)
+ - [Extensible — any skill can contribute a
guard](#extensible--any-skill-can-contribute-a-guard)
+ - [Install (user-scope)](#install-user-scope-1)
+ - [Verify](#verify-1)
- [Sandbox-error hint hook](#sandbox-error-hint-hook)
- [Why install it](#why-install-it)
- [Why install it user-scope, not
project-scope](#why-install-it-user-scope-not-project-scope-1)
- - [Install (user-scope)](#install-user-scope-1)
- - [Verify](#verify-1)
+ - [Install (user-scope)](#install-user-scope-2)
+ - [Verify](#verify-2)
- [Trade-offs](#trade-offs-1)
- [Sandbox-state status line](#sandbox-state-status-line)
- [Waiting-for-input terminal tint](#waiting-for-input-terminal-tint)
@@ -934,6 +938,95 @@ entirely) should produce no output and `exit=0`.
every Claude Code upgrade — same cadence as the
[Verification](#verification) section below.
+## Agent-guard deterministic guard hook
+
+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):
+
+- **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).
+
+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
+names the override. The dispatcher is stdlib-only and invoked as
+`python3 …/agent-guard.py`, fast-pathing everything that is not a
+`gh` / `git` command.
+
+### Extensible — any skill can contribute a guard
+
+The hook is **wired once**. Additional guards are discovered at
+runtime from the `guards.d` directory next to the script (plus any
+dir in `$STEWARD_GUARD_DIRS`). A skill contributes a guard by
+shipping one import-free `*.py` file that defines `guard(ctx)` (and
+an optional `TRIGGERS` list) — **no `settings.json` change**. The
+setup skills sync `guards.d` from the snapshot, so a new bundled or
+skill-contributed guard activates on the next `/magpie-setup` /
+`setup-isolated-setup-update`. See the
+[tool README](../../tools/agent-guard/README.md) for the contract and
+`guards.d/no_verify_commit.py` for the template.
+
+### Install (user-scope)
+
+```bash
+mkdir -p ~/.claude/scripts/guards.d
+cp /path/to/airflow-steward/tools/agent-guard/src/agent_guard/__init__.py \
+ ~/.claude/scripts/agent-guard.py
+cp /path/to/airflow-steward/tools/agent-guard/src/agent_guard/guards.d/*.py \
+ ~/.claude/scripts/guards.d/
+chmod +x ~/.claude/scripts/agent-guard.py
+```
+
+Then wire it into `~/.claude/settings.json` (project-scope
+`.claude/settings.json` works too) under `PreToolUse`, matched on
+`Bash` — append to an existing `Bash` matcher's `hooks` array if one
+is already present (e.g. the bypass-visibility hook):
+
+```jsonc
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"$HOME/.claude/scripts/agent-guard.py\"",
+ "timeout": 30
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+### Verify
+
+```bash
+echo '{"tool_name":"Bash","tool_input":{"command":"gh pr edit 5 --body
\"@alice hi\""}}' \
+ | python3 ~/.claude/scripts/agent-guard.py
+```
+
+Expected: a JSON object with `permissionDecision: "deny"` and a
+reason mentioning the `mention` guard. A plain command
+(`{"tool_input":{"command":"ls"}}`) produces no output and `exit=0`.
+
## Sandbox-error hint hook
Companion to the *Sandbox-bypass visibility hook* above — a
diff --git a/pyproject.toml b/pyproject.toml
index d3a65e01..a00414f0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -94,6 +94,7 @@ package = false
# file.
[tool.uv.workspace]
members = [
+ "tools/agent-guard",
"tools/agent-isolation",
"tools/egress-gateway",
"tools/cve-tool-vulnogram/generate-cve-json",
diff --git a/skills/pr-management-triage/SKILL.md
b/skills/pr-management-triage/SKILL.md
index a8be46a0..b18cb168 100644
--- a/skills/pr-management-triage/SKILL.md
+++ b/skills/pr-management-triage/SKILL.md
@@ -182,6 +182,13 @@ so the REST check is not required there — but the
subsequent
sweep that promotes the PR via `mark-ready` runs the check
exactly as documented above.
Implementation recipe: [`actions.md#mark-ready`](actions.md).
+This rule is **also enforced deterministically** by the
+agent-guard `PreToolUse` hook (the `mark-ready` guard) when the
+framework's secure setup is installed — it blocks the
+`--add-label "ready for maintainer review"` command if the head
+SHA still has `action_required` runs, independently of whether
+the skill remembered to check. See
+[`tools/agent-guard`](../../tools/agent-guard/README.md).
**Golden rule 2 — propose in groups, fall back to per-PR.** The
typical triage pass finds many PRs that need the same action
@@ -363,7 +370,12 @@ Two consequences the implementation MUST honour:
- **The folded block carries no `@`-mention.** A body edit that
introduces a fresh `@`-mention can itself notify; reference the
author as a backtick-quoted login instead. The no-`@`-mention
- rule is what makes the fold silent.
+ rule is what makes the fold silent. When the framework's secure
+ setup is installed, this is **enforced deterministically** by the
+ agent-guard `PreToolUse` hook (the `mention` guard): any
+ `@`-mention in a `gh pr edit --body` is blocked, and an
+ author-directed `gh pr comment` may only `@`-mention the PR
+ author — see [`tools/agent-guard`](../../tools/agent-guard/README.md).
- **Pings still notify.** `review-nudge`, `reviewer-ping`,
`request-author-confirmation`, `security-language`,
`suspicious-changes`, and stale-sweep notices always post a
diff --git a/skills/setup-isolated-setup-install/SKILL.md
b/skills/setup-isolated-setup-install/SKILL.md
index 88ca0526..6a7718f8 100644
--- a/skills/setup-isolated-setup-install/SKILL.md
+++ b/skills/setup-isolated-setup-install/SKILL.md
@@ -96,7 +96,14 @@ Drift severity:
desired merge against the existing file and asks for explicit
approval before writing. Re-installs / partial-state recoveries
are common — the skill must not blow away an unrelated
- pre-existing hook or `permissions.ask` rule.
+ pre-existing hook or `permissions.ask` rule. The desired merge
+ **includes the agent-guard `hooks.PreToolUse` entry** (matcher
+ `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.
- **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
@@ -118,7 +125,8 @@ Before walking any install step, confirm with the user:
have a clone, walk them through `git clone` first.
3. **Fresh install or re-install.** For a re-install on a partial
existing state, the skill must enumerate the existing wiring
- (project settings.json, user settings.json, hooks dir,
+ (project settings.json, user settings.json, hooks dir, the
+ agent-guard `hooks.PreToolUse` entry + `~/.claude/scripts/agent-guard.py`,
shell rc) before any merge so the user knows what is being
preserved vs replaced.
4. **Sync repo (optional).** Whether the user maintains a
diff --git a/skills/setup-isolated-setup-update/SKILL.md
b/skills/setup-isolated-setup-update/SKILL.md
index 375fdf5a..d1e91955 100644
--- a/skills/setup-isolated-setup-update/SKILL.md
+++ b/skills/setup-isolated-setup-update/SKILL.md
@@ -153,12 +153,25 @@ Walk each:
re-install path for each is
[`setup-isolated-setup-install`](../setup-isolated-setup-install/SKILL.md)
re-run on the affected Step P sub-step.
+
+ 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**.
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
(a credential type the team newly cares about), new
`allowedDomains` entries, new `permissions.deny` patterns
- for newly-discovered exfiltration paths. Report new entries
+ for newly-discovered exfiltration paths, **or the agent-guard
+ `hooks.PreToolUse` entry** (matcher `Bash`) if the user wired
+ the secure setup before the guard shipped. Report new entries
the user does not have; do not auto-merge.
5. **comdev MCP checkouts (`ponymail`, `apache-projects`).** These
ASF MCP servers are installed from a local `apache/comdev`
diff --git a/skills/setup/adopt.md b/skills/setup/adopt.md
index ea7e8af5..f96e9bf5 100644
--- a/skills/setup/adopt.md
+++ b/skills/setup/adopt.md
@@ -1081,6 +1081,39 @@ Four passes, in this order:
SUBSEQUENT adoption — it is the same pass `/magpie-setup
upgrade` runs after a snapshot refresh.
+ **The agent-guard PreToolUse hook is one such adopter-side
+ file.** The framework ships
+ [`tools/agent-guard`](../../tools/agent-guard/README.md) — a
+ deterministic Claude Code `PreToolUse` guard that blocks
+ `gh`/`git` commands which would ping maintainers, carry a
+ `Co-Authored-By` trailer, mark a PR ready prematurely, leak
+ security language publicly, or empty a PR via force-push. Sync
+ it like the post-checkout hook:
+ - 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).
+ - **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
+ exact snippet for the maintainer to apply** (or route it
+ through the `update-config` skill) rather than writing it:
+
+ ```json
+ { "matcher": "Bash", "hooks": [ { "type": "command",
+ "command": "python3
\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-guard.py\"",
+ "timeout": 30 } ] }
+ ```
+
+ Wiring happens **only once**; thereafter guards are
+ added/removed purely by syncing `guards.d` — no settings.json
+ change. If the `hooks.PreToolUse` entry is already present,
+ this pass only re-syncs the script + `guards.d`.
+
2. **Propagate to every worktree (run `worktree-init`
unconditionally).** The main is now adopted; any
pre-existing linked worktree of this repo still lacks
diff --git a/skills/setup/upgrade.md b/skills/setup/upgrade.md
index b760a7e8..9a29cd3f 100644
--- a/skills/setup/upgrade.md
+++ b/skills/setup/upgrade.md
@@ -384,6 +384,14 @@ rather than pulls in via symlink. Examples:
- `<repo-root>/.git/hooks/post-checkout` (the worktree-aware
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)
+ 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.
- 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 1c634d63..c79383f4 100644
--- a/skills/setup/verify.md
+++ b/skills/setup/verify.md
@@ -303,6 +303,28 @@ Two sub-checks on `<repo-root>/.git/hooks/post-checkout`:
remediation, no operator prompt needed; the sync
pass overwrites silently.
+### 8a. agent-guard PreToolUse hook installed and wired
+
+Three sub-checks for the deterministic guard
+([`tools/agent-guard`](../../tools/agent-guard/README.md)):
+
+1. **Script present + matches the snapshot.**
`<repo-root>/.claude/hooks/agent-guard.py`
+ exists and its content matches the snapshot's
+ `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.
+3. **Hook wired in settings.json.** `<repo-root>/.claude/settings.json`
+ has a `hooks.PreToolUse` entry (matcher `Bash`) whose command
+ runs `agent-guard.py`.
+ - ⚠ if missing — the script is present but not active; print
+ the one-time wiring snippet (see
+ [`adopt.md` Step
12](adopt.md#step-12--post-install-sync--worktree-propagation--sandbox-allowlist--sanity-check))
+ for the maintainer to apply (settings.json is agent-edit-denied).
+
### 8b. Sandbox-allowlist coverage of the current worktree
Defensive cross-check for
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
new file mode 100644
index 00000000..6f6e540e
--- /dev/null
+++ b/tools/agent-guard/README.md
@@ -0,0 +1,93 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [agent-guard](#agent-guard)
+ - [Guards](#guards)
+ - [Per-command overrides](#per-command-overrides)
+ - [Wiring](#wiring)
+ - [Tests](#tests)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# agent-guard
+
+**Capability:** capability:setup
+
+A deterministic Claude Code
[`PreToolUse`](https://code.claude.com/docs/en/hooks)
+guard dispatcher. It inspects every `Bash` command **before it runs** and
+**denies** the ones that would break a hard framework rule — protections that
+must not depend on the model remembering a `SKILL.md` instruction.
+
+It is **stdlib-only** and is invoked directly as
+`python3 <path>/agent_guard/__init__.py` (never via `uv run`) so it returns in
a
+few milliseconds for any command that is not a guarded `gh` / `git commit` /
+`git push`.
+
+## Guards
+
+| 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 |
+
+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`").
+
+## Per-command overrides
+
+Each guard is overridable by a **visible inline env assignment** so a
maintainer
+can consciously proceed:
+
+```bash
+STEWARD_ALLOW_MENTIONS=1 gh pr comment 123 --body "@reviewer please take
another look"
+STEWARD_ALLOW_COAUTHOR=1 git commit -m "…" # not for AI
co-authorship
+STEWARD_ALLOW_MARK_READY=1 gh pr edit 123 --add-label "ready for maintainer
review"
+STEWARD_ALLOW_SECURITY_LANG=1 gh pr create --title "…" # disclosure already
public
+STEWARD_ALLOW_EMPTY_PUSH=1 git push --force …
+STEWARD_GUARD_OFF=1 <any command> # disable all guards
once
+```
+
+`STEWARD_READY_LABEL` overrides the label string the `mark-ready` guard watches
+for (default `ready for maintainer review`).
+
+## Wiring
+
+The guard is registered as a `PreToolUse` hook on the `Bash` matcher in
+`.claude/settings.json`:
+
+```json
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ { "type": "command", "command": "python3
\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-guard.py\"", "timeout": 30 }
+ ]
+ }
+ ]
+ }
+}
+```
+
+`/magpie-setup` ships `agent_guard/__init__.py` as a single self-contained file
+into the adopter tree (`.claude/hooks/agent-guard.py`) and into the user-scope
+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.
+
+## Tests
+
+```bash
+uv run --project tools/agent-guard pytest
+```
+
+Table-driven tests feed synthetic `PreToolUse` events to `dispatch()` and
assert
+allow vs. deny. The `gh` / `git` lookups the guards make are monkeypatched.
diff --git a/tools/agent-guard/pyproject.toml b/tools/agent-guard/pyproject.toml
new file mode 100644
index 00000000..cf24db4f
--- /dev/null
+++ b/tools/agent-guard/pyproject.toml
@@ -0,0 +1,78 @@
+# 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.
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "agent-guard"
+version = "0.1.0"
+description = "Deterministic Claude Code PreToolUse guard dispatcher: blocks
gh/git commands that would ping maintainers, carry a Co-Authored-By trailer,
mark-ready prematurely, leak security language publicly, or empty a PR via
force-push."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+# stdlib-only on purpose — the hook fires on every Bash call and is invoked as
+# `python3 src/agent_guard/__init__.py`, never via `uv run`, so it must not
need
+# a built/installed environment to run.
+dependencies = []
+
+[project.scripts]
+agent-guard = "agent_guard:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/agent_guard"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+# guards.d holds duck-typed `guard(ctx)` plugin files (a drop-dir, not part of
+# the typed package). They are exercised by the discovery tests, not linted as
+# package code — same contract a skill-contributed guard in an adopter repo
has.
+extend-exclude = ["src/agent_guard/guards.d"]
+
+[tool.ruff.lint]
+select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF"]
+ignore = ["E501"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]
+
+[tool.mypy]
+python_version = "3.11"
+# Type-check the shipped engine; tests are exercised by pytest. guards.d is a
+# duck-typed plugin drop-dir (see the [tool.ruff] note).
+files = ["src"]
+exclude = ['(^|/)guards\.d/']
+warn_unused_ignores = true
+warn_redundant_casts = true
+warn_unreachable = true
+check_untyped_defs = true
+no_implicit_optional = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/agent-guard/src/agent_guard/__init__.py
b/tools/agent-guard/src/agent_guard/__init__.py
new file mode 100644
index 00000000..6c4c66cb
--- /dev/null
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -0,0 +1,670 @@
+# 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.
+
+"""Deterministic ``PreToolUse`` guard dispatcher for apache-steward.
+
+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:
+
+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:``
+ 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
+ base (an empty push to a PR head auto-closes the PR and revokes write).
+
+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
+not a guarded ``gh`` / ``git commit`` / ``git push`` (the fast path).
+
+Every guard is overridable, per command, by a visible inline env assignment so
a
+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
+``*.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
+``GuardContext`` and ``guards.d/no_verify_commit.py`` for the template. The
hook
+is wired once at setup; thereafter guards are added/removed by managing files
in
+``guards.d`` (which ``/magpie-setup`` keeps in sync from the snapshot).
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import re
+import shlex
+import subprocess
+import sys
+from collections.abc import Callable
+from pathlib import Path
+
+# --------------------------------------------------------------------------- #
+# Configuration / constants
+# --------------------------------------------------------------------------- #
+
+GLOBAL_OFF_ENV = "STEWARD_GUARD_OFF"
+READY_LABEL_ENV = "STEWARD_READY_LABEL"
+DEFAULT_READY_LABEL = "ready for maintainer review"
+
+# Shell control operators that separate one simple command from the next.
+SHELL_OPERATORS = frozenset({"&&", "||", "|", ";", "&", "|&"})
+
+# A GitHub @mention: an `@` that is NOT part of an email address (so it is not
+# preceded by a word char or a dot), followed by a login (or `org/team`).
+# Logins are 1-39 chars, alphanumeric or single hyphens.
+MENTION_RE =
re.compile(r"(?<![\w.@])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:/[A-Za-z0-9._-]+)?)")
+
+# Fenced code blocks and inline code spans — GitHub does not turn @mentions
+# inside them into notifications, so we strip them before scanning.
+_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.
+
+
+class Segment:
+ """One simple command from a (possibly compound) shell line.
+
+ ``argv`` has leading ``NAME=value`` env assignments stripped into ``env``;
+ ``raw`` is the original text of the segment (used for substring scans that
+ survive heredocs, e.g. the Co-Authored-By trailer).
+ """
+
+ def __init__(self, tokens: list[str], raw: str) -> None:
+ self.env: dict[str, str] = {}
+ i = 0
+ while i < len(tokens) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*",
tokens[i]):
+ name, _, value = tokens[i].partition("=")
+ self.env[name] = value
+ i += 1
+ self.argv: list[str] = tokens[i:]
+ self.raw = raw
+
+ def override(self, *names: str) -> bool:
+ """True if any of ``names`` (or the global off switch) is set truthy,
+ either as an inline env assignment on this segment or in the hook's own
+ environment."""
+ for name in (GLOBAL_OFF_ENV, *names):
+ val = self.env.get(name, os.environ.get(name))
+ if val not in (None, "", "0", "false", "False"):
+ return True
+ return False
+
+
+# --------------------------------------------------------------------------- #
+# Parsing helpers
+# --------------------------------------------------------------------------- #
+
+
+def split_segments(command: str) -> list[Segment]:
+ """Tokenise ``command`` and split it into simple-command segments on shell
+ operators. Returns an empty list if the command cannot be tokenised."""
+ try:
+ tokens = shlex.split(command, comments=False)
+ except ValueError:
+ return []
+ segments: list[Segment] = []
+ current: list[str] = []
+ for tok in tokens:
+ if tok in SHELL_OPERATORS:
+ if current:
+ segments.append(Segment(current, command))
+ current = []
+ else:
+ current.append(tok)
+ if current:
+ segments.append(Segment(current, command))
+ return segments
+
+
+def strip_code(text: str) -> str:
+ """Remove fenced code blocks and inline code spans — mentions inside them
do
+ not notify on GitHub."""
+ return _INLINE_CODE_RE.sub(" ", _FENCED_RE.sub(" ", text))
+
+
+def find_mentions(text: str) -> list[str]:
+ """Lower-cased GitHub mentions (logins and ``org/team``) in ``text``, with
+ code spans stripped first."""
+ return [m.group(1).lower() for m in MENTION_RE.finditer(strip_code(text))]
+
+
+def _opt_value(argv: list[str], short: str, long: str) -> str | None:
+ """Return the value of ``-x``/``--xxx`` (space- or ``=``-separated) or
None."""
+ for i, tok in enumerate(argv):
+ if tok in (short, long):
+ return argv[i + 1] if i + 1 < len(argv) else None
+ for prefix in (f"{long}=", f"{short}="):
+ if tok.startswith(prefix):
+ return tok[len(prefix) :]
+ return None
+
+
+def gh_subcommand(argv: list[str]) -> tuple[str, str] | None:
+ """For an argv whose first token is ``gh``, return ``(group, sub)``
skipping
+ global flags, e.g. ``(\"pr\", \"comment\")``. None if not a ``gh`` call."""
+ if not argv or argv[0] != "gh":
+ return None
+ rest = [t for t in argv[1:] if not t.startswith("-")]
+ if len(rest) >= 2:
+ return rest[0], rest[1]
+ return None
+
+
+def gh_body_text(argv: list[str], *, include_title: bool, read_files: bool) ->
str:
+ """Concatenate the inline ``--body`` (and optionally ``--title``) plus,
when
+ ``read_files`` is set, the contents of any ``--body-file``."""
+ parts: list[str] = []
+ body = _opt_value(argv, "-b", "--body")
+ if body:
+ parts.append(body)
+ if include_title:
+ title = _opt_value(argv, "-t", "--title")
+ if title:
+ parts.append(title)
+ if read_files:
+ path = _opt_value(argv, "-F", "--body-file")
+ if path and path != "-":
+ try:
+ with open(path, encoding="utf-8", errors="replace") as fh:
+ parts.append(fh.read())
+ except OSError:
+ parts.append("\x00UNREADABLE_BODY_FILE\x00")
+ return "\n".join(parts)
+
+
+def _run(args: list[str], cwd: str | None = None) -> str | None:
+ """Run a subprocess, returning stripped stdout, or None on any failure."""
+ try:
+ result = subprocess.run(
+ args,
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ timeout=GUARD_TIMEOUT,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return None
+ if result.returncode != 0:
+ return None
+ return result.stdout.strip()
+
+
+def _positional_target(argv: list[str], sub_index: int) -> str | None:
+ """First non-flag token after the subcommand — the PR/issue number or URL.
+ ``sub_index`` is the index of the subcommand token in ``argv``."""
+ i = sub_index + 1
+ while i < len(argv):
+ tok = argv[i]
+ if tok.startswith("-"):
+ # Skip a flag and, heuristically, its value when not ``=``-joined.
+ if "=" not in tok and tok not in ("--web",):
+ i += 2
+ else:
+ i += 1
+ continue
+ return tok
+ return None
+
+
+def _repo_flag(argv: list[str]) -> list[str]:
+ repo = _opt_value(argv, "-R", "--repo")
+ return ["--repo", repo] if repo else []
+
+
+# --------------------------------------------------------------------------- #
+# Guards — each returns a deny reason string, or None to allow.
+# --------------------------------------------------------------------------- #
+
+
+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
+ if not re.search(r"co-authored-by:", seg.raw, re.IGNORECASE):
+ return None
+ if seg.override("STEWARD_ALLOW_COAUTHOR"):
+ return None
+ return (
+ "agent-guard[commit-trailer]: this commit message carries a
'Co-Authored-By:' "
+ "trailer. Per AGENTS.md, agents are assistants, not authors — use a "
+ "'Generated-by: <agent name and version>' trailer instead and remove
the "
+ "Co-Authored-By line. Override (not for AI co-authorship):
STEWARD_ALLOW_COAUTHOR=1."
+ )
+
+
+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
+ forced = any(
+ t in ("-f", "--force") or t == "--force-with-lease" or
t.startswith("--force-with-lease=")
+ for t in seg.argv
+ )
+ if not forced:
+ return None
+ if seg.override("STEWARD_ALLOW_EMPTY_PUSH"):
+ return None
+
+ # Resolve the source ref being pushed: last `src[:dst]` positional, else
HEAD.
+ positionals = [t for t in seg.argv[2:] if not t.startswith("-")]
+ src = "HEAD"
+ if len(positionals) >= 2:
+ src = positionals[1].split(":", 1)[0] or "HEAD"
+ elif len(positionals) == 1 and _run(
+ ["git", "rev-parse", "--verify", "--quiet",
f"{positionals[0]}^{{commit}}"], cwd=cwd
+ ):
+ # A lone positional that resolves to a commit is the ref; else it is
the remote.
+ src = positionals[0]
+
+ default_ref = _run(["git", "rev-parse", "--abbrev-ref", "origin/HEAD"],
cwd=cwd)
+ if not default_ref:
+ return None # fail-open: no base to compare against.
+ base = _run(["git", "merge-base", default_ref, src], cwd=cwd)
+ if not base:
+ return None # fail-open.
+ count = _run(["git", "rev-list", "--count", f"{base}..{src}"], cwd=cwd)
+ if count is not None and count.isdigit() and int(count) == 0:
+ return (
+ f"agent-guard[empty-rebase]: refusing to force-push '{src}' — it
has 0 commits "
+ f"over its merge-base with {default_ref}. Pushing an empty branch
to a PR head "
+ "auto-closes the PR and revokes maintainer write access. Verify
the rebase "
+ "result is non-empty first. Override: STEWARD_ALLOW_EMPTY_PUSH=1."
+ )
+ return None
+
+
+# The framework's bundled guards. Skills contribute MORE 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
+# instant fast path. Bundled and contributed guards alike operate on the
+# `gh` / `git` outbound/destructive surface.
+GUARDED_HEADS = frozenset({"gh", "git"})
+
+# Colon-separated extra guard directories (in addition to the default
+# ``guards.d`` sibling of this script). Lets a checkout point the hook at
+# skill-owned guard dirs without moving files.
+GUARD_DIRS_ENV = "STEWARD_GUARD_DIRS"
+
+
+# --------------------------------------------------------------------------- #
+# Contributed-guard extension API
+# --------------------------------------------------------------------------- #
+
+
+class GuardContext:
+ """The API passed to every **contributed** guard — ``guard(ctx) -> str |
None``.
+
+ A skill adds a guard by dropping an import-free ``*.py`` file in a
discovered
+ ``guards.d`` directory that defines a module-level ``guard(ctx)`` (and an
+ optional ``TRIGGERS`` list of command families, e.g. ``["gh"]`` /
+ ``["git:commit"]``). Returning a string denies the command with that
reason;
+ returning ``None`` allows it. Everything the guard needs is on ``ctx`` — it
+ never imports ``agent_guard`` — so guards stay decoupled from the engine.
+ """
+
+ def __init__(self, seg: Segment, cwd: str | None) -> None:
+ self.seg = seg
+ self.cwd = cwd
+
+ @property
+ def argv(self) -> list[str]:
+ return self.seg.argv
+
+ @property
+ def raw(self) -> str:
+ return self.seg.raw
+
+ @property
+ def ready_label(self) -> str:
+ return os.environ.get(READY_LABEL_ENV, DEFAULT_READY_LABEL)
+
+ def override(self, *names: str) -> bool:
+ return self.seg.override(*names)
+
+ def gh_subcommand(self) -> tuple[str, str] | None:
+ return gh_subcommand(self.argv)
+
+ def opt(self, short: str, long: str) -> str | None:
+ return _opt_value(self.argv, short, long)
+
+ def gh_body(self, *, include_title: bool = False, read_files: bool = True)
-> str:
+ return gh_body_text(self.argv, include_title=include_title,
read_files=read_files)
+
+ def mentions(self, text: str) -> list[str]:
+ return find_mentions(text)
+
+ def positional_after(self, sub_token: str) -> str | None:
+ try:
+ idx = self.argv.index(sub_token)
+ except ValueError:
+ return None
+ return _positional_target(self.argv, idx)
+
+ def repo_flag(self) -> list[str]:
+ return _repo_flag(self.argv)
+
+ def run(self, args: list[str]) -> str | None:
+ return _run(args, cwd=self.cwd)
+
+
+def command_kinds(seg: Segment) -> set[str]:
+ """The command-family tags a segment matches, e.g. ``{"git",
"git:commit"}``."""
+ kinds: set[str] = set()
+ if not seg.argv:
+ return kinds
+ head = seg.argv[0]
+ kinds.add(head)
+ if len(seg.argv) > 1 and head in ("git", "gh"):
+ kinds.add(f"{head}:{seg.argv[1]}")
+ return kinds
+
+
+def guard_dirs() -> list[Path]:
+ """Directories scanned for contributed guards: ``$STEWARD_GUARD_DIRS``
entries
+ plus the ``guards.d`` sibling of this script."""
+ dirs: list[Path] = []
+ env = os.environ.get(GUARD_DIRS_ENV)
+ if env:
+ dirs.extend(Path(p) for p in env.split(os.pathsep) if p)
+ dirs.append(Path(__file__).resolve().parent / "guards.d")
+ seen: set[Path] = set()
+ out: list[Path] = []
+ for d in dirs:
+ if d not in seen and d.is_dir():
+ seen.add(d)
+ out.append(d)
+ return out
+
+
+def discover_guards() -> list[tuple[Callable[[GuardContext], str | None],
set[str] | None]]:
+ """Load contributed guards from the guard dirs. Each is returned with its
+ declared ``TRIGGERS`` set (or None to mean "any guarded command"). A guard
+ file that fails to import is skipped — a broken contribution must never
break
+ the user's shell."""
+ found: list[tuple[Callable[[GuardContext], str | None], set[str] | None]]
= []
+ for directory in guard_dirs():
+ for path in sorted(directory.glob("*.py")):
+ if path.name.startswith("_"):
+ continue
+ try:
+ spec =
importlib.util.spec_from_file_location(f"_agentguard_{path.stem}", path)
+ if spec is None or spec.loader is None:
+ continue
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ except Exception:
+ continue
+ fn = getattr(module, "guard", None)
+ if not callable(fn):
+ continue
+ triggers = getattr(module, "TRIGGERS", None)
+ found.append((fn, set(triggers) if triggers else None))
+ return found
+
+
+# --------------------------------------------------------------------------- #
+# Dispatch / entry point
+# --------------------------------------------------------------------------- #
+
+
+def dispatch(command: str, cwd: str | None = None) -> str | None:
+ """Return a deny reason for ``command``, or None to allow it."""
+ if not command:
+ return None
+ contributed: list[tuple[Callable[[GuardContext], str | None], set[str] |
None]] | None = None
+ for seg in split_segments(command):
+ if not seg.argv or seg.argv[0] not in GUARDED_HEADS:
+ continue # fast path — non-guarded command family
+ # Bundled guards (trusted, in-process; each self-filters its command).
+ for builtin in BUILTIN_GUARDS:
+ try:
+ reason = builtin(seg, cwd)
+ except Exception:
+ continue
+ if reason:
+ return reason
+ # Contributed guards, discovered lazily once per command.
+ if contributed is None:
+ contributed = discover_guards()
+ kinds = command_kinds(seg)
+ ctx = GuardContext(seg, cwd)
+ for fn, triggers in contributed:
+ if triggers is not None and not (triggers & kinds):
+ continue
+ try:
+ reason = fn(ctx)
+ except Exception:
+ continue
+ if reason:
+ return reason
+ return None
+
+
+def _emit_deny(reason: str) -> None:
+ json.dump(
+ {
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "deny",
+ "permissionDecisionReason": reason,
+ }
+ },
+ sys.stdout,
+ )
+ sys.stdout.write("\n")
+
+
+def main() -> int:
+ try:
+ event = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ return 0 # Malformed event — never break the user's shell; allow.
+ if not isinstance(event, dict) or event.get("tool_name") != "Bash":
+ return 0
+ command = str(event.get("tool_input", {}).get("command", ""))
+ cwd = event.get("cwd")
+ reason = dispatch(command, cwd if isinstance(cwd, str) else None)
+ if reason:
+ _emit_deny(reason)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/agent-guard/src/agent_guard/guards.d/no_verify_commit.py
b/tools/agent-guard/src/agent_guard/guards.d/no_verify_commit.py
new file mode 100644
index 00000000..f94de1f3
--- /dev/null
+++ b/tools/agent-guard/src/agent_guard/guards.d/no_verify_commit.py
@@ -0,0 +1,53 @@
+# 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.
+
+"""Bundled example contributed guard: block ``git commit --no-verify``.
+
+This is the **template** for skill-contributed guards. To add a guard, drop a
+file shaped like this into any discovered ``guards.d`` directory — no edit to
+``agent_guard`` and no change to the ``settings.json`` hook wiring is needed;
the
+dispatcher discovers it automatically.
+
+The contract:
+
+* ``TRIGGERS`` — optional list of command families this guard cares about
+ (``"gh"``, ``"git:commit"``, ``"git:push"``, …). Omit to run on every guarded
+ command. The dispatcher uses it as a cheap pre-filter.
+* ``guard(ctx)`` — return a deny-reason string to block the command, or
``None``
+ to allow it. ``ctx`` is the ``GuardContext`` (see ``agent_guard``); the guard
+ imports nothing.
+
+This guard blocks ``--no-verify`` / ``-n`` (which skips the prek hooks —
license
+headers, placeholders, lint, the validator) unless the maintainer overrides it.
+"""
+
+TRIGGERS = ["git:commit"]
+
+
+def guard(ctx):
+ if ctx.argv[:2] != ["git", "commit"]:
+ return None
+ if not any(tok in ("--no-verify", "-n") for tok in ctx.argv):
+ return None
+ if ctx.override("STEWARD_ALLOW_NO_VERIFY"):
+ return None
+ return (
+ "agent-guard[no-verify]: `git commit --no-verify` skips the prek hooks
"
+ "(license headers, placeholder check, lint, the skill/tool validator).
"
+ "Commit without --no-verify so the checks run, or override with "
+ "STEWARD_ALLOW_NO_VERIFY=1 if you are certain."
+ )
diff --git a/tools/agent-guard/tests/test_guards.py
b/tools/agent-guard/tests/test_guards.py
new file mode 100644
index 00000000..66df27b1
--- /dev/null
+++ b/tools/agent-guard/tests/test_guards.py
@@ -0,0 +1,406 @@
+# 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.
+
+import json
+
+import pytest
+
+import agent_guard
+
+
[email protected](autouse=True)
+def _clear_env(monkeypatch):
+ for name in (
+ "STEWARD_GUARD_OFF",
+ "STEWARD_ALLOW_MENTIONS",
+ "STEWARD_ALLOW_COAUTHOR",
+ "STEWARD_ALLOW_MARK_READY",
+ "STEWARD_ALLOW_SECURITY_LANG",
+ "STEWARD_ALLOW_EMPTY_PUSH",
+ "STEWARD_READY_LABEL",
+ ):
+ monkeypatch.delenv(name, raising=False)
+
+
+def fake_run(handler):
+ """Install a stub for agent_guard._run that dispatches on the argv."""
+
+ def _stub(args, cwd=None):
+ return handler(args)
+
+ return _stub
+
+
+# --------------------------------------------------------------------------- #
+# find_mentions / strip_code
+# --------------------------------------------------------------------------- #
+
+
[email protected](
+ "text, expected",
+ [
+ ("hello @alice and @bob-1", ["alice", "bob-1"]),
+ ("email [email protected] is not a mention", []),
+ ("team ping @apache/airflow-committers",
["apache/airflow-committers"]),
+ ("code span `@alice` does not notify", []),
+ ("fenced\n```\n@alice\n```\nblock", []),
+ ("[email protected] and plain text", []),
+ ("`backtick login` mention of @carol", ["carol"]),
+ ],
+)
+def test_find_mentions(text, expected):
+ assert agent_guard.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
+# --------------------------------------------------------------------------- #
+
+
+def test_commit_coauthor_denied():
+ reason = dispatch('git commit -m "fix\n\nCo-Authored-By: Someone <[email protected]>"')
+ assert reason and "Co-Authored-By" in reason
+
+
+def test_commit_coauthor_case_insensitive():
+ assert dispatch('git commit -m "x\n\nco-authored-by: a"') is not None
+
+
+def test_commit_generated_by_allowed():
+ assert dispatch('git commit -m "fix\n\nGenerated-by: Claude Code"') is None
+
+
+def test_commit_coauthor_override():
+ assert dispatch('STEWARD_ALLOW_COAUTHOR=1 git commit -m
"x\nCo-Authored-By: a"') is None
+
+
+# --------------------------------------------------------------------------- #
+# 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
+# --------------------------------------------------------------------------- #
+
+
+def _push_handler(count):
+ def handler(args):
+ if args[:3] == ["git", "rev-parse", "--abbrev-ref"]:
+ return "origin/main"
+ if args[:2] == ["git", "merge-base"]:
+ return "base1234"
+ if args[:3] == ["git", "rev-list", "--count"]:
+ return count
+ if args[:3] == ["git", "rev-parse", "--verify"]:
+ return "sha"
+ return None
+
+ return handler
+
+
+def test_empty_rebase_denied(monkeypatch):
+ monkeypatch.setattr(agent_guard, "_run", fake_run(_push_handler("0")))
+ reason = dispatch("git push --force-with-lease origin mybranch:mybranch")
+ assert reason and "0 commits" in reason
+
+
+def test_nonempty_force_push_allowed(monkeypatch):
+ monkeypatch.setattr(agent_guard, "_run", fake_run(_push_handler("3")))
+ assert dispatch("git push --force-with-lease origin mybranch") is None
+
+
+def test_non_force_push_allowed(monkeypatch):
+ def boom(args):
+ raise AssertionError("non-force push is not guarded")
+
+ monkeypatch.setattr(agent_guard, "_run", fake_run(boom))
+ assert dispatch("git push origin mybranch") is None
+
+
+def test_empty_rebase_failopen_no_base(monkeypatch):
+ monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: None))
+ assert dispatch("git push --force origin mybranch") is None
+
+
+def test_empty_rebase_override(monkeypatch):
+ monkeypatch.setattr(agent_guard, "_run", fake_run(_push_handler("0")))
+ assert dispatch("STEWARD_ALLOW_EMPTY_PUSH=1 git push --force origin b:b")
is None
+
+
+# --------------------------------------------------------------------------- #
+# dispatch / fast path / compound commands
+# --------------------------------------------------------------------------- #
+
+
[email protected](
+ "command",
+ [
+ "ls -la",
+ "git status",
+ "git log --oneline -5",
+ "grep -r foo .",
+ "gh pr view 5 --json title",
+ "",
+ ],
+)
+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_malformed_command_allows():
+ # Unbalanced quotes -> cannot tokenise -> allow (never break the shell).
+ assert dispatch('gh pr comment 5 --body "oops') 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"'}}
+ monkeypatch.setattr("sys.stdin", _Stdin(json.dumps(event)))
+ rc = agent_guard.main()
+ out = capsys.readouterr().out
+ payload = json.loads(out)
+ assert rc == 0
+ assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
+
+
+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 capsys.readouterr().out == ""
+
+
+def test_main_allows_malformed_stdin(monkeypatch, capsys):
+ monkeypatch.setattr("sys.stdin", _Stdin("not json"))
+ assert agent_guard.main() == 0
+ 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)
+
+
+class _Stdin:
+ def __init__(self, text):
+ self._text = text
+
+ def read(self):
+ return self._text
diff --git a/uv.lock b/uv.lock
index 3f7b6d54..dc5a4dd7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -12,6 +12,7 @@ exclude-newer-span = "P7D"
[manifest]
members = [
+ "agent-guard",
"agent-isolation",
"apache-steward",
"checker",
@@ -33,6 +34,11 @@ members = [
"vulnogram-api",
]
+[[package]]
+name = "agent-guard"
+version = "0.1.0"
+source = { editable = "tools/agent-guard" }
+
[[package]]
name = "agent-isolation"
version = "0.1.0"
@@ -364,7 +370,7 @@ requires-dist = [{ name = "proxy-py", specifier =
">=2.4,<3" }]
dev = [
{ name = "mypy", specifier = ">=1.11" },
{ name = "pytest", specifier = ">=8.0" },
- { name = "ruff", specifier = ">=0.15.15" },
+ { name = "ruff", specifier = ">=0.6" },
]
[[package]]