diff --git a/.claude/hooks/guard.py b/.claude/hooks/guard.py new file mode 100644 index 000000000..57da25dca --- /dev/null +++ b/.claude/hooks/guard.py @@ -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: ` 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 `, `uv add --dev `, 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.""" + 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() diff --git a/.claude/hooks/test_guard.py b/.claude/hooks/test_guard.py new file mode 100644 index 000000000..e7eb91eb4 --- /dev/null +++ b/.claude/hooks/test_guard.py @@ -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 + ) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..f75a3ca25 --- /dev/null +++ b/.claude/settings.json @@ -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\"" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 977aa9536..069e651b6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf7d8778c..b6ad20996 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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//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: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..4a96fdd40 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 970d5c69e..c4b798a5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]