chore(agents): load AGENTS.md in Claude Code and guard its rules

Claude Code discovers CLAUDE.md but not AGENTS.md, so the contributor
instructions were loading for nobody. The import shim is what our own docs
already recommend to users in docs/edge/en/guides/coding-tools/agents-md.mdx.

The PreToolUse guard enforces only rules already written in CONTRIBUTING.md
and AGENTS.md, so it adds no policy of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASfWmW3RGy4qAQm6s8U9jH
This commit is contained in:
Joao Moura
2026-08-08 16:29:40 -07:00
parent f7ba8e3521
commit 02e4c65239
7 changed files with 365 additions and 1 deletions

126
.claude/hooks/guard.py Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Block tool calls that violate a written contributor rule.
Wired as a Claude Code `PreToolUse` hook in `.claude/settings.json`. Every rule
here cites a rule already written down in `.github/CONTRIBUTING.md` or
`AGENTS.md` — this file enforces those documents, it does not add policy of its
own. A rule that is not written down there does not belong here.
Reads the hook event as JSON on stdin, writes a JSON decision on stdout, and
exits 0 either way. No subprocess, no filesystem writes, no network.
Escape hatch: include `# policy-override: <reason>` in a Bash command to state
an exception explicitly rather than working around the guard silently.
"""
from __future__ import annotations
import json
import re
import sys
from typing import Any
OVERRIDE_MARKER = "# policy-override:"
#: (pattern, reason). Patterns match the raw Bash command string.
BASH_RULES: tuple[tuple[str, str], ...] = (
(
r"(?:^|[;&|(\n])\s*(?:sudo\s+)?(?:pip3?|python3?\s+-m\s+pip)\s+"
r"(?:install|uninstall)\b",
"CONTRIBUTING.md (Dependency Management): do not use pip directly. "
"Use `uv add --package <pkg> <dep>`, `uv add --dev <dep>`, or `uv sync`.",
),
(
r"\bgit\b[^\n;&|]*\b(?:commit|push)\b[^\n;&|]*(?:--no-verify\b|\s-n(?=\s|$))",
"CONTRIBUTING.md (Commits): do not use --no-verify to skip hooks. "
"Fix what pre-commit reports instead.",
),
(
r"\b(?:rm|mv)\b[^\n]*\bdocs/images/",
"AGENTS.md (Changing Docs, rule 3): do not delete or rename files under "
"docs/images/ — frozen doc snapshots still reference them.",
),
(
r"\b(?:rm|mv|tee)\b[^\n]*\bdocs/v[0-9]",
"AGENTS.md (Changing Docs, rule 2): docs/v*/ are frozen release snapshots "
"managed by devtools. Edit docs/edge/en/ instead.",
),
)
FROZEN_DOCS_REASON = (
"AGENTS.md (Changing Docs, rule 2): docs/v*/ are frozen release snapshots "
"managed by devtools. Edit the MDX under docs/edge/en/ instead, then sync the "
"ar, ko, and pt-BR translations."
)
def deny(reason: str) -> None:
"""Emit a deny decision.
The T201 suppression is deliberate and not a lint escape: stdout is the hook
protocol itself — Claude Code parses this JSON to decide whether to proceed.
"""
print( # noqa: T201
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
)
)
def bash_violation(command: str) -> str | None:
"""Return the reason this command is blocked, or None if it is allowed."""
if OVERRIDE_MARKER in command:
return None
for pattern, reason in BASH_RULES:
if re.search(pattern, command):
return reason
return None
def edits_frozen_docs(path: str) -> bool:
"""True when path targets a frozen release snapshot under docs/v<digit>."""
match = re.search(r"(?:^|/)docs/v[0-9]", path)
return match is not None
def target_path(tool_input: dict[str, Any]) -> str:
"""The file a write-shaped tool is about to touch, or an empty string."""
for key in ("file_path", "notebook_path"):
value = tool_input.get(key)
if isinstance(value, str):
return value
return ""
def main() -> None:
try:
event = json.load(sys.stdin)
except Exception:
return
if not isinstance(event, dict):
return
tool_input = event.get("tool_input")
if not isinstance(tool_input, dict):
return
command = tool_input.get("command")
if isinstance(command, str):
reason = bash_violation(command)
if reason is not None:
deny(reason)
return
if edits_frozen_docs(target_path(tool_input)):
deny(FROZEN_DOCS_REASON)
if __name__ == "__main__":
main()

181
.claude/hooks/test_guard.py Normal file
View File

@@ -0,0 +1,181 @@
"""Behavior tests for the contributor-rule guard hook.
Run with: uv run pytest .claude/hooks/test_guard.py -q
The module is loaded by path rather than imported by name: `.claude/hooks` is not
a package and must not be added to `sys.path`, which would leak into other tests.
"""
from __future__ import annotations
import importlib.util
import io
import json
from pathlib import Path
import sys
from types import ModuleType
from typing import Any
import pytest
def _load_guard() -> ModuleType:
path = Path(__file__).parent / "guard.py"
spec = importlib.util.spec_from_file_location("_guard_under_test", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
guard = _load_guard()
BLOCKED_COMMANDS: list[tuple[str, str]] = [
("pip install ruff", "do not use pip directly"),
("pip3 install ruff", "do not use pip directly"),
("sudo pip install ruff", "do not use pip directly"),
("python -m pip install ruff", "do not use pip directly"),
("uv sync && pip install ruff", "do not use pip directly"),
("pip uninstall crewai", "do not use pip directly"),
("git commit -m 'x' --no-verify", "do not use --no-verify"),
("git commit -n -m 'x'", "do not use --no-verify"),
("git push --no-verify", "do not use --no-verify"),
("git -c user.name=x commit --no-verify -m 'x'", "do not use --no-verify"),
("uv sync && git commit --no-verify -m 'x'", "do not use --no-verify"),
("rm docs/images/flow.png", "docs/images/"),
("mv docs/images/a.png docs/images/b.png", "docs/images/"),
("rm -rf docs/v1.15.0", "frozen release snapshots"),
]
ALLOWED_COMMANDS: list[str] = [
"uv add --package crewai httpx",
"uv add --dev pytest",
"uv sync --all-groups --all-extras",
"uv pip install -e .",
"uv run pytest lib/crewai/tests -x -q",
"uv run pytest -n auto --dist=loadfile lib/crewai/tests",
"git commit -m 'feat(agents): add skill loader'",
"git push origin main",
'grep -rn "pip install" .github/CONTRIBUTING.md',
"echo 'never use --no-verify' >> notes.txt",
'grep -rn "no-verify" .github/CONTRIBUTING.md',
"uv run pytest -n auto lib/crewai/tests",
"uv run pip-audit --skip-editable --ignore-vuln PYSEC-2024-277",
"rm docs/edge/en/scratch.mdx",
"cat docs/v1.15.0/index.mdx",
]
@pytest.mark.parametrize(("command", "expected"), BLOCKED_COMMANDS)
def test_blocked_commands_report_the_rule_they_violate(
command: str, expected: str
) -> None:
reason = guard.bash_violation(command)
assert reason is not None, f"expected {command!r} to be blocked"
assert expected in reason
@pytest.mark.parametrize("command", ALLOWED_COMMANDS)
def test_allowed_commands_pass_through(command: str) -> None:
assert guard.bash_violation(command) is None
def test_blocked_reasons_cite_a_committed_document() -> None:
for command, _ in BLOCKED_COMMANDS:
reason = guard.bash_violation(command)
assert reason is not None
assert "CONTRIBUTING.md" in reason or "AGENTS.md" in reason
def test_override_marker_allows_a_stated_exception() -> None:
command = "pip install vendored.whl # policy-override: offline wheel, no index"
assert guard.bash_violation(command) is None
def test_override_marker_does_not_leak_to_other_commands() -> None:
assert guard.bash_violation("pip install ruff") is not None
@pytest.mark.parametrize(
"path",
[
"docs/v1.15.0/index.mdx",
"/repo/docs/v1.15.0/concepts/agents.mdx",
"/repo/docs/v2/index.mdx",
],
)
def test_frozen_snapshot_paths_are_blocked(path: str) -> None:
assert guard.edits_frozen_docs(path) is True
@pytest.mark.parametrize(
"path",
[
"docs/edge/en/index.mdx",
"docs/edge/pt-BR/index.mdx",
"lib/crewai/src/crewai/agent.py",
"docs/versioning.md",
"",
],
)
def test_editable_paths_are_allowed(path: str) -> None:
assert guard.edits_frozen_docs(path) is False
def _run_main(event: Any, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any] | None:
"""Drive main() with a hook event, returning the parsed decision if any."""
payload = event if isinstance(event, str) else json.dumps(event)
monkeypatch.setattr(sys, "stdin", io.StringIO(payload))
out = io.StringIO()
monkeypatch.setattr(sys, "stdout", out)
guard.main()
written = out.getvalue().strip()
if not written:
return None
decision: dict[str, Any] = json.loads(written)
return decision
def test_main_denies_a_violating_bash_call(monkeypatch: pytest.MonkeyPatch) -> None:
decision = _run_main({"tool_input": {"command": "pip install ruff"}}, monkeypatch)
assert decision is not None
output = decision["hookSpecificOutput"]
assert output["hookEventName"] == "PreToolUse"
assert output["permissionDecision"] == "deny"
assert "do not use pip directly" in output["permissionDecisionReason"]
def test_main_denies_a_write_to_a_frozen_snapshot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
decision = _run_main(
{"tool_input": {"file_path": "/repo/docs/v1.15.0/index.mdx"}}, monkeypatch
)
assert decision is not None
reason = decision["hookSpecificOutput"]["permissionDecisionReason"]
assert "frozen release snapshots" in reason
def test_main_stays_silent_on_an_allowed_call(monkeypatch: pytest.MonkeyPatch) -> None:
assert _run_main({"tool_input": {"command": "uv sync"}}, monkeypatch) is None
@pytest.mark.parametrize(
"event",
["", "not json", "[]", '"a string"', "{}", '{"tool_input": null}'],
)
def test_malformed_events_never_block(
event: str, monkeypatch: pytest.MonkeyPatch
) -> None:
assert _run_main(event, monkeypatch) is None
def test_a_bash_call_is_not_evaluated_as_a_file_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A command mentioning docs/v* is judged by the Bash rules, not the path rule."""
assert (
_run_main({"tool_input": {"command": "cat docs/v1.15.0/x.mdx"}}, monkeypatch)
is None
)

24
.claude/settings.json Normal file
View File

@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.py\""
}
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.py\""
}
]
}
]
}
}

11
.gitignore vendored
View File

@@ -26,7 +26,16 @@ plan.md
conceptual_plan.md
build_image
chromadb-*.lock
.claude
# Everything under .claude is personal except the shared agent config below.
# Git cannot re-include a file whose parent directory is excluded, so this
# excludes the contents rather than the directory itself.
.claude/*
!.claude/settings.json
!.claude/hooks/
.claude/hooks/*
!.claude/hooks/guard.py
!.claude/hooks/test_guard.py
CLAUDE.local.md
.crewai/memory
blogs/*
secrets/*

View File

@@ -61,6 +61,18 @@ repos:
language: system
pass_filenames: false
stages: [pre-push, manual]
- repo: local
hooks:
- id: agent-guard-hook
name: agent-guard-hook
# --confcutdir keeps the root conftest out: it assumes every test lives
# under lib/<package>/tests/, which these do not.
entry: >-
bash -c 'source .venv/bin/activate && uv run pytest
.claude/hooks/test_guard.py -q --confcutdir=.claude/hooks'
language: system
pass_filenames: false
files: ^\.claude/hooks/
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.10.1
hooks:

11
CLAUDE.md Normal file
View File

@@ -0,0 +1,11 @@
# CrewAI
Contributor instructions for this repo live in `AGENTS.md`, shared by every coding
agent. Claude Code does not discover that file on its own, so this import is what
loads it — keep the two in sync by editing `AGENTS.md`, never by duplicating rules
here.
@AGENTS.md
Personal, uncommitted preferences belong in `CLAUDE.local.md`, which Claude Code loads
alongside this file and which `.gitignore` keeps out of the repo.

View File

@@ -115,6 +115,7 @@ ignore-decorators = ["typing.overload"]
"lib/cli/tests/**/*.py" = ["S101", "RET504", "S105", "S106"] # Allow assert statements in tests
"lib/crewai-core/tests/**/*.py" = ["S101", "RET504", "S105", "S106"] # Allow assert statements in tests
"lib/devtools/tests/**/*.py" = ["S101"]
".claude/hooks/test_*.py" = ["S101"] # Allow assert statements in tests
[tool.mypy]