hooks, permissions, path-scoped 규칙으로 Claude Code 프로젝트 컨벤션을 강제하기
README 링크에서 발견 · today
Python 저장소에서 느슨했던 CLAUDE.md 규칙을 강제 적용되는 설정으로 바꿨다.
What they did
작성자는 Python 저장소의 기존 Claude Code 설정(CLAUDE.md, /do-issue 및 /auto-issues 명령, ruff-format PostToolUse 훅, CI)을 감사한 결과, 대부분의 관례가 Claude가 무시할 수 있는 산문에 불과하다는 점을 발견했다. 작성자는 검증된 격차를 정리한 상세한 티켓을 작성했다: pyproject.toml에 [tool.ruff] 블록이 없어서(따라서 import 정렬이나 bugbear 규칙, 줄 길이 제한도 없음) 죽은 [tool.isort] 프로필이 있었고, mypy는 설치만 되어 있고 설정도 실행도 되지 않았으며, pytest가 의존성으로 빠져 있어(모든 명령에 --with pytest가 필요), [tool.pytest.ini_options]도 없었고, .vscode/settings.json은 pytest 대신 unittest를 가리키고 있었다. .pre-commit-config.yaml 없이 pre-commit git 훅이 설치되어 있어 매 커밋마다 PRE_COMMIT_ALLOW_NO_CONFIG=1을 강제해야 했다. CLAUDE.md에는 main에 절대 푸시하지 말라고 되어 있었지만 이를 강제하는 것은 없었다. 훅은 하나만 존재했고, .env는 보호되지 않았으며, .claude/settings.json에는 permissions 블록이 없었다. 영역 지식은 .claude/preflight.md에 있었는데, 이는 /auto-issues만 읽을 수 있었다. 제안된 수정안은 다음과 같다: target-version py311, 현재 코드에 맞는 줄 길이, lint.select E/F/I/B/UP를 갖춘 [tool.ruff] 추가; isort 제거; mypy의 운명 결정; pytest ini 옵션과 함께 pytest를 dev 그룹에 추가하고 VS Code 수정; uv.lock ruff 버전에 고정된 .pre-commit-config.yaml 추가; main으로의 푸시, --force, --no-verify, .env 읽기를 차단하는 PreToolUse 훅 추가; PostToolUse를 확장하여 ruff check --fix를 실행하고 오류를 다시 보고; allow/deny 권한 목록 추가; 그리고 server/agent, client, testing을 위한 경로 범위 .claude/rules/ 파일 생성. 작성자는 또한 위생 문제로 중복된 grill-me/grilling 스킬과 추적되지 않는 .claude/launch.json을 지적했으며, 작업을 네 개의 PR로 나눌 것을 제안했다.
How it went
병합된 코드가 아니라 범위가 정해진 체크리스트 형식의 티켓으로 인수 기준과 함께 전달되었다: ruff check/format과 세 개의 pytest 스위트 모두 로컬과 CI에서 통과해야 하고, 일반 git commit이 env 우회 없이 동작해야 하며, Claude 세션은 main 푸시, force-push, .env 읽기를 눈에 띄게 차단해야 하고, 규칙은 해당 경로에만 로드되어야 한다(/memory로 검증). 작성자는 작업을 명시적으로 네 개의 PR(스타일+pre-commit, 테스트+규칙, 훅+권한, 규칙+preflight 정리)로 나누었고, 커버리지 게이트, CI 내 e2e, .mcp.json은 범위 밖으로 명시했다.
Worth knowing
이것은 검증된 구현이 아니라 계획입니다 — 통과하는 인수 기준은 하나도 제시되지 않았습니다. 줄 길이와 mypy 유지/제거 결정은 열린 상태로 남아 있습니다. Stop 훅은 선택 사항이며 의도적으로 테스트를 건너뜁니다. 커버리지 게이트, CI에서의 e2e(OpenAI 키 시크릿 필요), 프로젝트 .mcp.json은 보류되었습니다. 훅 동작은 사용 중인 버전의 Claude Code PreToolUse 종료 코드 의미론과 권한 구문 일치에 의존합니다.
Wiring
# 1. `pyproject.toml`
```toml
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
# [tool.isort] removed — ruff's "I" ruleset replaces it
```
mypy: keep it out of this pass. Ruff covers lint/format; add mypy later as a separate decision (it needs its own config, plugin choices, and a baseline of type errors to fix). Don't half-configure it now.
# 2. pytest as a dev dependency + config
```toml
[dependency-groups]
dev = [
"pytest",
# ...existing dev deps
]
[tool.pytest.ini_options]
testpaths = ["tests", "e2e"]
pythonpath = ["."]
```
Then strip `--with pytest` from:
- `CLAUDE.md` (any `uv run --with pytest ...` → `uv run pytest ...`)
- CI workflow (same substitution)
- preflight script (same substitution)
Fix `.vscode/settings.json` — point the interpreter at the uv venv and enable pytest discovery:
```json
{
"python.defaultInterpreterPath": ".venv/bin/python",
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": ["tests", "e2e"]
}
```
# 3. `.pre-commit-config.yaml`
Pin to the same ruff version as `uv.lock` (check `uv.lock` for the resolved `ruff` version and substitute below):
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9 # match uv.lock
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
```
With this in place, `PRE_COMMIT_ALLOW_NO_CONFIG` can be removed from the environment/CI.
# 4. `.claude/settings.json` hooks
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "cmd=$(jq -r '.tool_input.command'); if echo \"$cmd\" | grep -Eq 'git push.*(main|--force)|--no-verify'; then echo 'Blocked: push to main, --force, or --no-verify' >&2; exit 2; fi"
}
]
},
{
"matcher": "Read|Edit|Write",
"hooks": [
{
"type": "command",
"command": "p=$(jq -r '.tool_input.file_path // .tool_input.path // empty'); if echo \"$p\" | grep -Eq '(^|/)\\.env$'; then echo 'Blocked: .env is off-limits (use .env.example)' >&2; exit 2; fi"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "p=$(jq -r '.tool_input.file_path // empty'); case \"$p\" in *.py) uv run ruff format \"$p\" && uv run ruff check --fix \"$p\" ;; esac"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "uv run ruff check ."
}
]
}
]
}
}
```
Notes:
- `exit 2` from a `PreToolUse` hook blocks the tool call and surfaces stderr to Claude.
- The `.env` matcher allows `.env.example` because the regex anchors on `\.env$`.
- The `Stop` hook is optional; drop it if the full-repo check is too slow on every turn end.
# 5. Permissions block
Add to the same `.claude/settings.json`:
```json
{
"permissions": {
"allow": [
"Bash(uv run ruff *)",
"Bash(uv run * pytest *)",
"Bash(make e2e)",
"Bash(gh issue view *)",
"Bash(gh pr view *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)"
],
"deny": [
"Read(./.env)",
"Bash(git push --force*)",
"Bash(rm -rf *)"
]
}
}
```
# 6. `.claude/rules/*.md`
Each file starts with `paths:` frontmatter so it only loads for matching files.
`.claude/rules/server-agent.md`:
```markdown
---
paths:
- "server/agent/**"
---
# Server / agent rules
- ...
```
`.claude/rules/client.md`:
```markdown
---
paths:
- "client/**"
---
# Client rules
- ...
```
`.claude/rules/testing.md`:
```markdown
---
paths:
- "**/tests/**"
- "e2e/**"
---
# Testing rules
- ...
```
Then move general facts (project layout, commands, conventions that apply everywhere) out of `.claude/preflight.md` into `CLAUDE.md` or the appropriate rule file. `preflight.md` should keep only what's genuinely preflight-specific.
Verify with `/memory` that each rule file loads only when a matching path is in context — open a file under `server/agent/` and confirm `server-agent.md` appears, then open one under `client/` and confirm `client.md` appears and `server-agent.md` does not.Try it yourself
# Claude Code Setup Audit & Enforcement Plan ## Audit Findings ### 1. Python tooling (`pyproject.toml`) - **`[tool.ruff]`**: verify `target-version` matches the interpreter floor, `line-length` matches the actual codebase (measure with `ruff format --check` diff count), and `lint.select = ["E", "F", "I", "B", "UP"]`. Missing `UP`/`B` means modern-syntax and bugbear rules are silently off. - **`[tool.isort]`**: dead config — ruff's `I` ruleset supersedes it. Remove to avoid two sources of truth. - **`mypy`**: present but unconfigured (no `[tool.mypy]` section, no `strict`/`disallow_untyped_defs`). Either configure it or drop it from CI to avoid a no-op gate. ### 2. Testing - Confirm `pytest` is in `[dependency-groups].dev` (or `[project.optional-dependencies].dev`), not just installed ad hoc. - Add `[tool.pytest.ini_options]` with `testpaths = ["tests"]` and `pythonpath = ["."]` (or `["src"]`) so `pytest` runs from repo root without `PYTHONPATH` hacks. - `.vscode/settings.json`: check `python.testing.pytestEnabled = true` and `python.testing.pytestArgs = ["tests"]`; otherwise the editor runs unittest discovery and diverges from CI. ### 3. Pre-commit - If `.git/hooks/pre-commit` exists but `.pre-commit-config.yaml` does not, the hook is unmanaged (likely a hand-written script). Replace with `pre-commit install` + a committed config so every clone gets the same gate. ### 4. `.claude/settings.json` - Missing `permissions.allow` / `permissions.deny` — every Bash/Edit call prompts or is silently allowed depending on user config. - No `hooks` block — no guardrails on destructive git operations or secret-file reads. --- ## Proposed Changes (split into PRs) ### PR 1 — Python tooling baseline - `pyproject.toml`: fix `[tool.ruff]` (`target-version`, `line-length`, `lint.select = ["E","F","I","B","UP"]`), delete `[tool.isort]`, add `[tool.mypy]` (`strict = true`, `files = ["src"]`) or remove mypy from CI. - Add `[tool.pytest.ini_options]` with `testpaths` and `pythonpath`; move `pytest` into the dev dependency group. - `.vscode/settings.json`: enable pytest discovery. **Acceptance criteria** - `uv run ruff check .` and `uv run ruff format --check .` pass on `main`. - `uv run pytest` collects tests from repo root with no env vars set. - `uv run mypy` (if kept) exits 0 or is removed from CI. - No `[tool.isort]` section remains; `grep -r isort pyproject.toml` returns nothing. ### PR 2 — Pre-commit - Add `.pre-commit-config.yaml` pinning `astral-sh/ruff-pre-commit` to the exact `ruff` version in `uv.lock` (both `ruff` and `ruff-format` hooks). - Remove any hand-written `.git/hooks/pre-commit`; document `pre-commit install` in `CLAUDE.md`. **Acceptance criteria** - `pre-commit run --all-files` passes on a clean checkout. - Hook `rev` matches `uv.lock` ruff version (assert in CI or a test). - Fresh clone + `pre-commit install` blocks a commit with a lint error. ### PR 3 — Claude Code hooks (`.claude/settings.json`) - **PreToolUse on `Bash`**: block `git push` to `main`, `--force`/`-f`, and `--no-verify`; `exit 2` with a human-readable reason. - **PreToolUse on `Read|Edit|Write`**: block paths matching `.env` (and `.env.*` except `.env.example`); allow `.env.example`. - **PostToolUse on `Edit|Write`**: run `ruff format` then `ruff check --fix` on the touched file; surface remaining errors back to the model. - Add `permissions.allow` (e.g. `Bash(uv run pytest:*)`, `Bash(uv run ruff:*)`, `Read`, `Edit`, `Write`) and `permissions.deny` (e.g. `Bash(git push --force:*)`, `Read(./.env)`, `Read(./.env.*)`). **Acceptance criteria** - Attempting `git push origin main` from Claude Code exits 2 with a reason; same for `--force` and `--no-verify`. - Reading `.env` is denied; reading `.env.example` succeeds. - Editing a Python file with a lint error triggers PostToolUse and the error appears in the transcript. - `permissions.deny` entries are honored even when `allow` would otherwise match. ### PR 4 — Path-scoped rules - Add `.claude/rules/server-agent.md`, `.claude/rules/client.md`, `.claude/rules/testing.md`, each with `paths:` frontmatter (e.g. `paths: ["server/**", "agent/**"]`, `paths: ["client/**"]`, `paths: ["tests/**", "**/test_*.py"]`). - Trim `CLAUDE.md` to ≤60 lines: keep repo map, commands, and pointers to rules; move domain conventions into the scoped files. **Acceptance criteria** - `wc -l CLAUDE.md` ≤ 60. - Each rule file has valid `paths:` frontmatter and is only injected when editing matching files (verify via a scoped edit). - No duplicated guidance between `CLAUDE.md` and rule files.