Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .claude/hooks/pre-commit-verification.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude/hooks/session-start-loader.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/framework-invariants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions scripts/fixtures/failing-project/package.json
Original file line number Diff line number Diff line change
@@ -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)\""
}
}
5 changes: 5 additions & 0 deletions scripts/fixtures/slow-gate/gate.sh
Original file line number Diff line number Diff line change
@@ -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
109 changes: 109 additions & 0 deletions scripts/hook-tests.d/00-baseline.sh
Original file line number Diff line number Diff line change
@@ -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 <<JSON
{"tool_name":"Write","tool_input":{"file_path":"src/config.ts","content":"const key = \\"${_akid_prefix}${_akid_suffix}\\";"}}
JSON
)" \
"CLAUDE_PROJECT_DIR=$ptu_dir" \
"ask-json"

run_case \
"pre-tool-use-validator: ordinary Write is allowed (silent)" \
".claude/hooks/pre-tool-use-validator.sh" \
"$(cat <<'JSON'
{"tool_name":"Write","tool_input":{"file_path":"src/example.ts","content":"export const answer = 42;"}}
JSON
)" \
"CLAUDE_PROJECT_DIR=$ptu_dir" \
"exit0-silent"

# --- session-start-loader.sh: multi-agent count survives the ls->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
110 changes: 110 additions & 0 deletions scripts/test-hooks.sh
Original file line number Diff line number Diff line change
@@ -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="<exact case name>" ./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"
Loading