Compare commits

...

4 Commits

Author SHA1 Message Date
Joao Moura
7cbba7bd0a fix(agents): close two heredoc bypasses in the guard
A body piped into a shell is executable, not data, so it is no longer
stripped: bash <<'EOF' containing a blocked command is matched again.

The opener's own line is now preserved, so a redirect written after the
delimiter is still seen. Only the body between delimiters is replaced.

Both found by review on the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASfWmW3RGy4qAQm6s8U9jH
2026-08-08 21:24:04 -07:00
Joao Moura
96cec9a08e docs(agents): document the guard's quoted-argument limitation
Heredoc bodies are stripped before matching, but quoted arguments cannot be:
stripping them would let a genuinely destructive quoted command through. The
override marker is the intended answer, so say so where contributors will look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASfWmW3RGy4qAQm6s8U9jH
2026-08-08 21:18:48 -07:00
Joao Moura
04382bc9ba fix(agents): close guard bypasses and false denies from review
- pip3.12 and python3.12 -m pip bypassed the direct-pip rule
- git push -n is --dry-run, not skip-hooks, so it was denied wrongly
- rm -rf docs/images without a trailing slash was not matched
- path rules crossed shell separators, denying unrelated later segments
- writes into docs/v*/ via redirection, cp, or sed -i were not covered
- heredoc bodies were matched as commands, blocking commit messages that
  merely discuss a blocked command — found by the guard blocking this commit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASfWmW3RGy4qAQm6s8U9jH
2026-08-08 21:17:02 -07:00
Joao Moura
02e4c65239 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
2026-08-08 16:29:40 -07:00
7 changed files with 500 additions and 1 deletions

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

@@ -0,0 +1,181 @@
#!/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.
Known limitation: rules match the command text, so a command that passes a
protected path to another program as a quoted argument — writing a commit
message or a PR comment about `docs/images`, for example — is denied even
though it changes nothing. Heredoc bodies are stripped before matching because
they are unambiguously data, but quoted arguments are not: `rm -rf "docs/..."`
is a real command, so stripping quotes would open a bypass. Use the override
marker in that case.
"""
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], ...] = (
(
# Version-suffixed interpreters (pip3.12, python3.12 -m pip) bypass a bare
# pip3? match. `uv pip` is unaffected: the anchor requires command position.
r"(?:^|[;&|(\n])\s*(?:sudo\s+)?"
r"(?:pip(?:[23](?:\.\d+)?)?|python(?:[23](?:\.\d+)?)?\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`.",
),
(
# `-n` is only the skip-hooks flag for commit; on push it means --dry-run,
# which is safe and must stay allowed.
r"\bgit\b[^\n;&|]*\b(?:commit|push)\b[^\n;&|]*--no-verify\b"
r"|\bgit\b[^\n;&|]*\bcommit\b[^\n;&|]*\s-n(?=\s|$)",
"CONTRIBUTING.md (Commits): do not use --no-verify to skip hooks. "
"Fix what pre-commit reports instead.",
),
(
# Stops at shell separators so a later segment merely naming the path does
# not trigger, and matches the directory with or without a trailing slash.
r"\b(?:rm|mv)\b[^\n;&|]*\bdocs/images(?![\w.-])",
"AGENTS.md (Changing Docs, rule 3): do not delete or rename files under "
"docs/images/ — frozen doc snapshots still reference them.",
),
(
# Write verbs and output redirection. Read-only access (cat, grep, less)
# is deliberately allowed. `cp` is matched in either direction: failing
# closed on a copy out of the tree is cheaper than missing a copy into it.
r"\b(?:rm|mv|cp|tee|sed\s+-i)\b[^\n;&|]*\bdocs/v[0-9]"
r"|>>?\s*[^\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,
}
}
)
)
#: A heredoc opener, its same-line remainder, and the body up to the delimiter.
HEREDOC = re.compile(
r"(<<-?\s*[\"']?(\w+)[\"']?)([^\n]*)\n.*?^[ \t]*\2\b",
re.DOTALL | re.MULTILINE,
)
#: Commands that execute their heredoc body instead of consuming it as data.
SHELL_INTERPRETER = re.compile(r"\b(?:bash|sh|zsh|dash|ksh|eval)\b")
def strip_heredocs(command: str) -> str:
"""Replace data heredoc bodies with a placeholder.
A heredoc body is usually data — a commit message, a file being written, a
PR body — not a command, and matching rules against it produces false
denials, such as blocking a commit whose message merely discusses a
protected path.
Two things keep that from becoming a bypass. A body piped into a shell
(`bash <<'EOF'`) is executable, so it is left intact and still matched. And
the opener's own line is preserved in full, so a redirection written after
the delimiter (`cat <<'EOF' > docs/v1/x.mdx`) is still seen.
"""
def replace(match: re.Match[str]) -> str:
line_start = command.rfind("\n", 0, match.start()) + 1
opener_line = command[line_start : match.start()] + match.group(1)
if SHELL_INTERPRETER.search(opener_line):
return match.group(0)
return f"{match.group(1)}{match.group(3)}\nHEREDOC_BODY\n{match.group(2)}"
return HEREDOC.sub(replace, command)
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
inspectable = strip_heredocs(command)
for pattern, reason in BASH_RULES:
if re.search(pattern, inspectable):
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()

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

@@ -0,0 +1,261 @@
"""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"),
# Version-suffixed interpreters must not bypass the pip rule.
("pip3.12 install ruff", "do not use pip directly"),
("pip2 install ruff", "do not use pip directly"),
("python3.12 -m pip install ruff", "do not use pip directly"),
# The protected directory named without a trailing slash.
("rm -rf docs/images", "docs/images/"),
("mv docs/images docs/img", "docs/images/"),
# Writes into a frozen snapshot by redirection or copy, not just rm/mv.
("echo x > docs/v1.15.0/index.mdx", "frozen release snapshots"),
("cat tmp.mdx >> docs/v1.15.0/index.mdx", "frozen release snapshots"),
("cp new.mdx docs/v1.15.0/index.mdx", "frozen release snapshots"),
("sed -i 's/a/b/' docs/v1.15.0/index.mdx", "frozen release snapshots"),
("tee docs/v1.15.0/index.mdx < new.mdx", "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",
# Read-only access to a frozen snapshot is allowed; only writes are blocked.
"cat docs/v1.15.0/index.mdx",
"grep -rn 'agents' docs/v1.15.0/ > /tmp/hits.txt",
"less docs/v1.15.0/index.mdx",
"ls docs/images/",
# `-n` on push is --dry-run, not skip-hooks.
"git push -n origin main",
"git push --dry-run origin main",
# A protected path named in a later, unrelated command segment.
"rm /tmp/scratch.txt && ls docs/images/",
"rm /tmp/scratch.txt && cat docs/v1.15.0/index.mdx",
# Paths that merely start with the same prefix.
"rm docs/imagesets/old.png",
]
@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
HEREDOC_COMMIT = """git commit -m "$(cat <<'EOF'
fix(agents): close guard bypasses found in review
- rm -rf docs/images without a trailing slash was not matched
- pip install was reachable via pip3.12
EOF
)"
"""
def test_heredoc_bodies_are_data_not_commands() -> None:
"""A commit message discussing a blocked command must not itself be blocked."""
assert guard.bash_violation(HEREDOC_COMMIT) is None
def test_a_heredoc_writing_into_a_frozen_snapshot_is_still_blocked() -> None:
"""Stripping the body must not hide a redirection on the command line."""
command = "cat > docs/v1.15.0/index.mdx <<'EOF'\nsome new content\nEOF\n"
reason = guard.bash_violation(command)
assert reason is not None
assert "frozen release snapshots" in reason
def test_a_redirect_after_the_heredoc_delimiter_is_still_seen() -> None:
"""The opener's own line must survive stripping, redirect included."""
command = "cat <<'EOF' > docs/v1.15.0/index.mdx\nsome new content\nEOF\n"
reason = guard.bash_violation(command)
assert reason is not None
assert "frozen release snapshots" in reason
@pytest.mark.parametrize(
("command", "expected"),
[
("bash <<'EOF'\nrm -rf docs/images/a.png\nEOF\n", "docs/images/"),
("sh <<'EOF'\npip install ruff\nEOF\n", "do not use pip directly"),
("bash -s <<'EOF'\ngit commit --no-verify -m x\nEOF\n", "--no-verify"),
],
)
def test_a_heredoc_piped_into_a_shell_is_executable_not_data(
command: str, expected: str
) -> None:
"""A body a shell will run must still be matched, not stripped as data."""
reason = guard.bash_violation(command)
assert reason is not None, f"expected {command!r} to be blocked"
assert expected in reason
def test_a_command_after_a_heredoc_is_still_inspected() -> None:
command = "cat <<'EOF' > notes.txt\njust notes\nEOF\npip install ruff\n"
reason = guard.bash_violation(command)
assert reason is not None
assert "do not use pip directly" 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]