mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-10 08:21:54 +00:00
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:
126
.claude/hooks/guard.py
Normal file
126
.claude/hooks/guard.py
Normal 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
181
.claude/hooks/test_guard.py
Normal 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
24
.claude/settings.json
Normal 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
11
.gitignore
vendored
@@ -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/*
|
||||
|
||||
@@ -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
11
CLAUDE.md
Normal 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.
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user