diff --git a/.claude/hooks/_env_guard.py b/.claude/hooks/_env_guard.py new file mode 100644 index 00000000..ac17e67e --- /dev/null +++ b/.claude/hooks/_env_guard.py @@ -0,0 +1,38 @@ +"""Helper for pre_tool_env_guard.sh — reads tool-call JSON from stdin and +prints a "match" line if the call would touch .env or secrets/. Empty +output means no match (allow).""" +import json +import re +import sys + + +def main() -> None: + try: + data = json.load(sys.stdin) + except Exception: + return + + ti = data.get("tool_input", {}) or {} + fp = ( + ti.get("file_path", "") + or ti.get("path", "") + or ti.get("notebook_path", "") + ) + cmd = ti.get("command", "") or "" + + path_pattern = re.compile(r"(^|/)(\.env(\.|$)|secrets/)") + cmd_pattern = re.compile( + r"(^|[ \t;|&])\s*(cat|less|more|head|tail|cp|mv|rm)\s+" + r"[^|;&]*(?:\.env|secrets/)" + ) + + if fp and path_pattern.search(fp): + print(f"path:{fp}") + return + if cmd and cmd_pattern.search(cmd): + print(f"command:{cmd[:120]}") + return + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/pre_tool_env_guard.sh b/.claude/hooks/pre_tool_env_guard.sh new file mode 100755 index 00000000..b388a451 --- /dev/null +++ b/.claude/hooks/pre_tool_env_guard.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# PreToolUse hook — extra explicit refusal for any attempt to touch +# .env / secrets paths, on top of the permissions.deny rules in +# .claude/settings.json. Provides a clearer, friendlier message and a +# log line. +# +# Receives the tool-call payload as JSON on stdin: +# { "tool_name": "Read|Write|Edit|Bash|...", +# "tool_input": { "file_path": "...", "command": "..." } } +# +# Exit codes: +# 0 → allow (silent) +# 2 → block; stderr is shown to Claude so it knows why + +set -euo pipefail + +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" + +matches="$(python3 "$HOOK_DIR/_env_guard.py" 2>/dev/null || true)" + +if [ -n "$matches" ]; then + echo "🚫 Blocked: attempt to access protected path (env / secrets)." >&2 + echo " Detail: $matches" >&2 + echo " Reason: .env files and secrets/ are off-limits to Claude in this project." >&2 + echo " To grant a one-off exception, ask the developer to read the file and paste the relevant value." >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/hooks/session_start.sh b/.claude/hooks/session_start.sh new file mode 100755 index 00000000..d669a256 --- /dev/null +++ b/.claude/hooks/session_start.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# SessionStart hook — print branch + working-tree status to give Claude +# (and the developer) immediate context at the start of a session. +set -euo pipefail + +# Run only inside a git repo +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + exit 0 +fi + +branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')" +upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || echo '')" + +# Ahead / behind vs upstream +ahead_behind="" +if [ -n "$upstream" ]; then + counts="$(git rev-list --left-right --count "${upstream}...HEAD" 2>/dev/null || echo '0 0')" + behind="$(echo "$counts" | awk '{print $1}')" + ahead="$(echo "$counts" | awk '{print $2}')" + if [ "$ahead" != "0" ] || [ "$behind" != "0" ]; then + ahead_behind=" (ahead $ahead, behind $behind vs $upstream)" + fi +fi + +# Working-tree status +dirty_count="$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')" +if [ "$dirty_count" = "0" ]; then + state="clean" +else + state="$dirty_count uncommitted change(s)" +fi + +echo "📍 Branch: ${branch}${ahead_behind} — ${state}" diff --git a/.claude/onboarding/step2_setup/01_settings_analysis.md b/.claude/onboarding/step2_setup/01_settings_analysis.md new file mode 100644 index 00000000..1a8eb0fa --- /dev/null +++ b/.claude/onboarding/step2_setup/01_settings_analysis.md @@ -0,0 +1,74 @@ +# Step 2 — Settings & Hooks Analysis + +Decisions made while configuring `.claude/settings.json` and the hooks +(Step 2). + +## Date: 2026-05-06 + +## Project Context (PHASE 1 findings) +- **Backend:** Python (Flask, `uv`). No Ruff / Black / pre-commit config. +- **Frontend:** Vue 3 + Vite. No Prettier / ESLint config. +- **Sensitive paths:** `.env`, `.env.example`. No `secrets/` folder + exists today; rule blocks the path proactively. User uploads land in + `backend/uploads/`. CodeGraph index lives in `.codegraph/`. +- **Project decision (Step 1):** No enforced formatter — match the + surrounding file's style. + +## Decisions + +| Question | Decision | +|----------|----------| +| Q1 — Allow more bash? | **Yes.** Added `npm run:*`, `npm test:*`, `npm install:*`, `git status`, `git diff:*`, `git log:*`, `git show:*`, `git add:*`, `git branch:*`, `git checkout:*`, `git commit:*`, `git restore:*`, `uv run:*`, `uv sync:*`, `docker compose:*`, `docker-compose:*`. | +| Q2 — Deny additions? | **Yes.** Added Read/Write/Edit denials for `*/uploads/*` and `*/.codegraph/*`. | +| Q3 — PostToolUse formatter? | **Skipped.** No formatter configured in this project. Matches the established convention (Step 1 — "no enforced formatter at present"). Add later if/when the team adopts one. | +| Q4 — PreToolUse `.env` guard hook? | **Added** as a friendly, logged refusal layered on top of the `permissions.deny` rules. | +| Q5 — SessionStart hook? | **Both** — branch + status. Single line: `📍 Branch: (ahead N, behind M vs upstream) — clean / N uncommitted change(s)`. | +| Q6 — `.gitignore` for `settings.local.json`? | **Already done in Step 0** — `.claude/settings.local.json` and `.claude/.credentials.json` are ignored. | + +## What Was Created / Updated + +### `.claude/settings.json` +- **Permissions:** + - `allow` — safe nav (`cd`, `ls`, `find`, `cat`, `mkdir`), `npm` / + `uv` task running, common read-only and staging git commands, + docker-compose for the recommended deployment path. + - `deny` — `.env*`, `secrets/`, `uploads/`, `.codegraph/` (Read / + Write / Edit), destructive bash (`rm -f*`, `rm -rf*`, + `git push -f*`, `git push --force*`). +- **Hooks:** + - `SessionStart` → `.claude/hooks/session_start.sh` + - `PreToolUse` (matcher: `Read|Write|Edit|Bash|NotebookEdit`) → + `.claude/hooks/pre_tool_env_guard.sh` + +### `.claude/hooks/session_start.sh` +- Prints branch + ahead/behind vs. upstream + working-tree state + (clean / N uncommitted changes) on session start. +- Silent exit when not inside a git repo. + +### `.claude/hooks/pre_tool_env_guard.sh` + `_env_guard.py` +- Defence-in-depth on top of `permissions.deny`. +- Inspects `tool_input.file_path` and `tool_input.command` for + `.env*` / `secrets/` references. +- Blocks (`exit 2`) with a clear, friendly stderr message: + - what was blocked (path or command excerpt) + - why (project policy) + - how to grant a one-off exception (developer copy-pastes the + relevant value) +- Tested against positive and negative inputs (Read on `/foo/.env`, + bash `cat /foo/.env`, plain `ls`, plain file Read). + +### Stop hook +- **Not configured here** — Step 6 (Quality & Review) covers the + full quality-gate Stop hook. Out of scope for Step 2. + +## Test Results (PHASE 6 verification) +- `session_start.sh` prints the expected single-line summary. +- `pre_tool_env_guard.sh`: + - Read `/foo/.env` → blocked, exit 2 ✓ + - Read `/foo/safe.txt` → allowed, exit 0 ✓ + - Bash `cat /foo/.env` → blocked, exit 2 ✓ + - Bash `ls` → allowed, exit 0 ✓ +- `settings.json` parses as valid JSON ✓ + +## Next +- Step 3: Planning Tool Integration (MCP server, sprint context). diff --git a/.claude/settings.json b/.claude/settings.json index 869cac4c..a9a1d7e2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,24 @@ "Bash(ls:*)", "Bash(find:*)", "Bash(cat:*)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "Bash(npm run:*)", + "Bash(npm test:*)", + "Bash(npm install:*)", + "Bash(git status)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git add:*)", + "Bash(git branch:*)", + "Bash(git checkout:*)", + "Bash(git commit:*)", + "Bash(git restore:*)", + "Bash(uv run:*)", + "Bash(uv sync:*)", + "Bash(docker compose:*)", + "Bash(docker-compose:*)" ], "deny": [ "Read(*/.env*)", @@ -14,10 +31,39 @@ "Write(*/secrets/*)", "Edit(*/.env*)", "Edit(*/secrets/*)", + "Read(*/uploads/*)", + "Write(*/uploads/*)", + "Edit(*/uploads/*)", + "Read(*/.codegraph/*)", + "Write(*/.codegraph/*)", + "Edit(*/.codegraph/*)", "Bash(rm -f*)", "Bash(rm -rf*)", "Bash(git push -f*)", "Bash(git push --force*)" ] + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session_start.sh" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Read|Write|Edit|Bash|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre_tool_env_guard.sh" + } + ] + } + ] } }