diff --git a/.claude/hooks/pre-commit-verification.sh b/.claude/hooks/pre-commit-verification.sh index c98d274..b4fc778 100755 --- a/.claude/hooks/pre-commit-verification.sh +++ b/.claude/hooks/pre-commit-verification.sh @@ -42,7 +42,6 @@ if [ -f "$VERIFICATION_FILE" ]; then fi # Detect project type and available tools -VERIFICATION_COMMANDS="" DETECTED_TOOLS="" # TypeScript/JavaScript (pnpm preferred per tech strategy) @@ -74,12 +73,6 @@ fi # Python (uv preferred per tech strategy) if [ -f "$PROJECT_DIR/pyproject.toml" ]; then - if command -v uv &> /dev/null; then - PY_MGR="uv run" - else - PY_MGR="python -m" - fi - # Ruff (preferred per tech strategy) if grep -q "ruff" "$PROJECT_DIR/pyproject.toml" 2>/dev/null; then DETECTED_TOOLS="$DETECTED_TOOLS ruff" diff --git a/.claude/hooks/session-start-loader.sh b/.claude/hooks/session-start-loader.sh index 71b9c44..ea05ecc 100755 --- a/.claude/hooks/session-start-loader.sh +++ b/.claude/hooks/session-start-loader.sh @@ -32,7 +32,7 @@ echo "{\"session_id\": \"$SESSION_ID\", \"started\": \"$(date -Iseconds)\", \"so CONTEXT="" # Check for active swarm agents -ACTIVE_AGENTS=$(ls -1 "$STATE_DIR"/session_*.json 2>/dev/null | wc -l | tr -d ' ') +ACTIVE_AGENTS=$(find "$STATE_DIR" -maxdepth 1 -name 'session_*.json' -type f 2>/dev/null | wc -l | tr -d ' ') if [ "$ACTIVE_AGENTS" -gt 1 ]; then CONTEXT="$CONTEXT diff --git a/.github/workflows/framework-invariants.yml b/.github/workflows/framework-invariants.yml index 36f2a5e..c5ce752 100644 --- a/.github/workflows/framework-invariants.yml +++ b/.github/workflows/framework-invariants.yml @@ -18,6 +18,35 @@ jobs: - name: Run invariant checks run: ./scripts/check-invariants.sh + shellcheck: + name: Shellcheck + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Run shellcheck + # SC2319/SC2164 (scripts/check-invariants.sh) and SC1087 (scripts/init-framework.sh) + # are pre-existing findings out of scope for this change (no hook, no harness case + # protects them yet) — tracked for a future cleanup pass rather than fixed blind here. + # File list is filesystem-derived (find, not a hardcoded list) so new hooks/scripts/ + # fixtures are covered automatically — see scripts/test-hooks.sh's own extension model. + run: | + # shellcheck disable=SC2046 # deliberate word-split: one file path per shellcheck arg + shellcheck -e SC2319,SC2164,SC1087 $(find .claude/hooks scripts -name '*.sh' -type f | sort) + + hook-tests: + name: Hook behavior tests + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Run hook behavior harness + run: ./scripts/test-hooks.sh + secret-scan: name: Secret scan (Trivy) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a35c94..7e7151d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Python stack pack (`.claude/templates/stack-packs/python/`): the second stack under the three-file convention — Python 3.13+, uv, Ruff, Litestar, msgspec, asyncpg golden path with the pytest/mypy gate suite `pre-commit-verification.sh` detects, plus a matching `ci-gates.yml` - Go stack pack (`.claude/templates/stack-packs/go/`): the third stack under the three-file convention — Go 1.25+, Gin or Chi, sqlc + pgx v5, golangci-lint golden path with the go vet/go test/go build gate suite `pre-commit-verification.sh` detects, plus a matching `ci-gates.yml`; completes the initial trio and graduates the "Generated by tailor" delivery tier from roadmap to shipped in `docs/skills.md` - Rust stack pack (`.claude/templates/stack-packs/rust/`): the fourth stack under the three-file convention — Rust 2024 edition, Tokio/Monoio, Axum, sqlx/rkyv golden path with the cargo test/clippy/fmt gate suite `pre-commit-verification.sh` detects, plus a matching `ci-gates.yml`; first demand-driven addition per the ADR's Rule of Three, added on owner request rather than as part of the initial trio — exercises the pack discovery mechanism (`adr_stack_packs.md` Decision 4: a new pack is a new directory, zero engine change) end to end +- `scripts/test-hooks.sh` + `scripts/hook-tests.d/`: table-driven hook-behavior test harness (same `report()`/PASS-FAIL idiom as `check-invariants.sh`), extended by dropping new case files in `hook-tests.d/` — never by editing the runner; includes a `path_without_jq` fail-open shim. `00-baseline.sh` pins the current behavior of the five hooks `artifacts/plan_framework_hardening.md` (unit U15) touches next. Two new blocking CI jobs in `framework-invariants.yml`: `shellcheck` (`.claude/hooks/*.sh` + `scripts/*.sh`, filesystem-derived file list) and `hook-tests` (runs the new harness); `scripts/fixtures/failing-project/` and `scripts/fixtures/slow-gate/` fixtures for later units' gate-behavior ACs. Fixes the 3 pre-existing shellcheck findings in the touched hooks (`pre-commit-verification.sh`'s dead `VERIFICATION_COMMANDS`/`PY_MGR`, `session-start-loader.sh`'s `ls`→`find`) so the new job starts green - `.claude/rules/security.md` "Untrusted Content & Prompt Injection" section: tool-fetched web content, issue/PR text, and third-party repo file contents are data, not instructions — quote and confirm with the user before acting on any embedded directive, review repo config that executes (hooks, settings, MCP definitions) before opening unfamiliar repos, and bound the blast radius with least-privilege credentials; `threat-modeling`'s new "Agent-Specific Threats" subsection covers indirect injection, tool poisoning, instruction-hierarchy violation, over-scoped tokens, and config-as-code execution paths; `worker-research` and `worker-explorer` each gain a matching Constraints entry - `swarm-coordination`'s canonical "Budget & Waves" section: orchestrators declare a token/wave ceiling at dispatch and, on hitting it, stop to report spend and remaining work before continuing — framed explicitly as a cost circuit-breaker, not step-repetition detection; dispatched task titles carry a `[Wave N/M]` prefix; cross-referenced from `swarm-plan`, `swarm-execute`, and `swarm-research` near their concurrency-cap guidance. Illustrative opt-in `docs/examples/worker-budget-hook.sh` (`PreToolUse` on `Task` + `SubagentStop`) demonstrates a concurrent-worker counter that warns past 8 in flight — not wired into `settings.json` - `.github/PULL_REQUEST_TEMPLATE.md`: Summary / What Changed / Provenance (author: agent+model or human; gates run with results; pushed SHA) / Risk Tier (low|medium|high + one-line blast radius) / Test Plan; `land-the-plane`'s PR step now points at filling the Provenance and Risk Tier fields before requesting review; `swarm-review` routes `risk:high` to a full multi-perspective review and `risk:low` to a lite pass, defined as a single-perspective quality review that skips the security/performance/architecture panel diff --git a/scripts/fixtures/failing-project/package.json b/scripts/fixtures/failing-project/package.json new file mode 100644 index 0000000..e9be12c --- /dev/null +++ b/scripts/fixtures/failing-project/package.json @@ -0,0 +1,9 @@ +{ + "name": "failing-project-fixture", + "version": "0.0.0", + "private": true, + "description": "Dependency-free fixture for hook-behavior tests: a project whose test script always fails (no npm install needed) — exercises the pre-commit gate's red path.", + "scripts": { + "test": "node -e \"process.exit(1)\"" + } +} diff --git a/scripts/fixtures/slow-gate/gate.sh b/scripts/fixtures/slow-gate/gate.sh new file mode 100755 index 0000000..fbb499f --- /dev/null +++ b/scripts/fixtures/slow-gate/gate.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Fixture: a "gate" that runs longer than a single gate should. Used by later +# units' harness cases (U5b) to prove a per-gate timeout produces an honest +# "ask" rather than a silent kill or an indefinite hang. +sleep 8 diff --git a/scripts/hook-tests.d/00-baseline.sh b/scripts/hook-tests.d/00-baseline.sh new file mode 100755 index 0000000..35a70e0 --- /dev/null +++ b/scripts/hook-tests.d/00-baseline.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# scripts/hook-tests.d/00-baseline.sh — characterization cases pinning the +# CURRENT behavior of the five hooks this framework-hardening plan touches +# next (artifacts/plan_framework_hardening.md, unit U15). These must pass +# against the *unmodified* hooks; a later unit changing one of these +# behaviors does so deliberately, by editing the case here, not by accident. +# Add new behavior in a new NN-name.sh file alongside this one. + +# A throwaway CLAUDE_PROJECT_DIR isolates state-file/lock-file hooks +# (pre-commit-verification, stop-validator, pre-tool-use-validator all read +# or write .claude/hooks/.state or .locks under it) from this repo's own, +# genuinely-dirty-during-development working tree. +_fresh_dir() { mktemp -d "${TMPDIR:-/tmp}/hook-test-fixture.XXXXXX"; } + +# --- pre-commit-verification.sh: advisory on git commit -------------------- +commit_dir=$(_fresh_dir) +run_case \ + "pre-commit-verification: git commit emits advisory additionalContext" \ + ".claude/hooks/pre-commit-verification.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git commit -m \"wip\""}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$commit_dir" \ + "stdout-contains:PRE-COMMIT VERIFICATION REQUIRED" + +# --- pre-push-main-blocker.sh: explicit push to main is denied ------------- +run_case \ + "pre-push-main-blocker: explicit 'git push origin main' denied" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push origin main"}} +JSON +)" \ + "" \ + "deny-json" + +# --- stop-validator.sh: silent on a clean tree ------------------------------ +clean_repo=$(_fresh_dir) +git -C "$clean_repo" init -q >/dev/null 2>&1 +git -C "$clean_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m init >/dev/null 2>&1 +run_case \ + "stop-validator: silent (exit 0, no output) on a clean tree" \ + ".claude/hooks/stop-validator.sh" \ + "$(cat <<'JSON' +{"session_id":"test-session","stop_hook_active":false} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$clean_repo" \ + "exit0-silent" + +# --- pre-tool-use-validator.sh: secret-shaped content asks; plain content allows +ptu_dir=$(_fresh_dir) +# Split across two vars so this fixture file's own text never contains a +# contiguous AWS-key-shaped string (would false-positive this repo's own +# Trivy secret-scan CI job). +_akid_prefix="AKIA" +_akid_suffix="TESTTESTTESTTEST" +run_case \ + "pre-tool-use-validator: Write with AWS-key-shaped content asks" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + "$(cat <find fix -- +ssl_dir=$(_fresh_dir) +mkdir -p "$ssl_dir/.claude/hooks/.state" +echo '{}' > "$ssl_dir/.claude/hooks/.state/session_aaaaaaaa.json" +run_case \ + "session-start-loader: counts multiple active-agent session files (SWARM STATUS)" \ + ".claude/hooks/session-start-loader.sh" \ + "$(cat <<'JSON' +{"source":"startup","session_id":"bbbbbbbb-0000-0000-0000-000000000000"} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$ssl_dir" \ + "stdout-contains:Active agents in project: 2" + +# --- fail-open: with jq absent, every hook this plan touches exits 0, silently +nojq_path=$(path_without_jq) +for hook in \ + ".claude/hooks/pre-commit-verification.sh" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + ".claude/hooks/stop-validator.sh" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + ".claude/hooks/session-start-loader.sh" +do + run_case \ + "fail-open: $hook exits 0 silently with jq absent" \ + "$hook" \ + '{}' \ + "PATH=$nojq_path" \ + "exit0-silent" +done diff --git a/scripts/test-hooks.sh b/scripts/test-hooks.sh new file mode 100755 index 0000000..916ba13 --- /dev/null +++ b/scripts/test-hooks.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# scripts/test-hooks.sh — table-driven behavior runner for .claude/hooks/*.sh. +# +# Every behavioral claim about a hook (deny / ask / silent-allow / advisory +# text / fail-open when jq is absent) gets a case here instead of living only +# in a comment or a plan doc. Extend this harness by adding a new +# scripts/hook-tests.d/NN-name.sh file that calls run_case (below) — never by +# editing this file's flow (open/closed). Mirrors check-invariants.sh's own +# report()/PASS-FAIL idiom so both harnesses read the same way. +# +# Usage: ./scripts/test-hooks.sh # run every case +# SKIP="" ./scripts/test-hooks.sh # skip one case +# +# Exits non-zero if any non-skipped case fails. + +# shellcheck disable=SC2329,SC2317 # report/skipped/run_case/path_without_jq are a library +# for scripts/hook-tests.d/*.sh, called only after being source'd at runtime — invisible +# to shellcheck's static reachability analysis of this file in isolation. Both codes are +# the same false positive; which one fires depends on the shellcheck version (SC2329 +# "never invoked" locally, SC2317 "unreachable" on newer CI runners). +set -u +cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)" || exit 1 +FAIL=0 +SKIP="${SKIP:-}" +CASES_DIR="scripts/hook-tests.d" + +# Resolved once, via the harness's own unmodified PATH — a case's env_override +# (below) must only change what the *hook* sees (e.g. no jq reachable), never +# whether this harness can still find bash to run it. +BASH_BIN=$(command -v bash) + +skipped() { case " $SKIP " in *" $1 "*) return 0;; *) return 1;; esac; } +report() { # $1=case $2=status(0 pass) $3=detail + if skipped "$1"; then echo "SKIP $1"; return; fi + if [ "$2" -eq 0 ]; then echo "PASS $1"; else echo "FAIL $1 — ${3:-}"; FAIL=1; fi +} + +# path_without_jq: symlinks every executable on PATH into a fresh temp dir, +# skipping jq itself. Pass the result as a case's env_override +# ("PATH=$(path_without_jq)") to prove a hook's fail-open path when jq is +# genuinely unreachable — not just "we didn't call it". First match wins (no +# -f on the symlink) so ordinary PATH precedence is preserved across dupes. +path_without_jq() { + local shim dir base name + shim=$(mktemp -d "${TMPDIR:-/tmp}/hook-test-nojq.XXXXXX") + local -a dirs + IFS=':' read -ra dirs <<< "$PATH" + for dir in "${dirs[@]}"; do + [ -d "$dir" ] || continue + for base in "$dir"/*; do + [ -x "$base" ] || continue + name=$(basename "$base") + [ "$name" = "jq" ] && continue + ln -s "$base" "$shim/$name" 2>/dev/null + done + done + printf '%s' "$shim" +} + +# run_case: the harness's one extension point. +# $1 name human-readable case name (also the SKIP key) +# $2 hook hook path, repo-root-relative (e.g. .claude/hooks/x.sh) +# $3 stdin_json JSON payload piped to the hook's stdin (build via heredoc) +# $4 env_override optional "KEY=VAL KEY2=VAL2" pairs (env(1) syntax), or "" +# to inherit the harness's own environment unchanged +# $5 expectation one of: +# deny-json permissionDecision:"deny" present +# ask-json permissionDecision:"ask" present +# exit0-silent exit 0 AND empty stdout +# stdout-empty empty stdout (exit code ignored) +# stdout-contains:SUB stdout contains literal SUB +run_case() { + local name="$1" hook="$2" stdin_json="$3" env_override="$4" expectation="$5" + local out rc ok=1 reason="" + # shellcheck disable=SC2086 # deliberate word-split: "KEY=VAL ..." pairs for env(1), or empty + out=$(printf '%s' "$stdin_json" | env $env_override "$BASH_BIN" "$hook" 2>/dev/null) + rc=$? + case "$expectation" in + deny-json) + printf '%s' "$out" | grep -qE '"permissionDecision"[[:space:]]*:[[:space:]]*"deny"' && ok=0 ;; + ask-json) + printf '%s' "$out" | grep -qE '"permissionDecision"[[:space:]]*:[[:space:]]*"ask"' && ok=0 ;; + exit0-silent) + [ "$rc" -eq 0 ] && [ -z "$out" ] && ok=0 ;; + stdout-empty) + [ -z "$out" ] && ok=0 ;; + stdout-contains:*) + printf '%s' "$out" | grep -qF -- "${expectation#stdout-contains:}" && ok=0 ;; + *) + reason="unrecognized expectation '$expectation'" ;; + esac + reason="${reason:-exit=$rc output=$(printf '%s' "$out" | tr '\n' ' ' | cut -c1-200)}" + report "$name" "$ok" "$reason" +} + +# Auto-source every case file — adding cases never means editing this file. +if [ -d "$CASES_DIR" ]; then + for f in "$CASES_DIR"/*.sh; do + [ -f "$f" ] || continue + # shellcheck source=/dev/null + source "$f" + done +else + echo "FAIL hook-tests.d missing: $CASES_DIR" + FAIL=1 +fi + +echo "" +[ "$FAIL" -eq 0 ] && echo "ALL HOOK TESTS GREEN" || echo "HOOK TEST FAILURES PRESENT" +exit "$FAIL"