forge-orkes 0.41.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-forge.js +245 -43
- package/package.json +5 -1
- package/template/.claude/agents/doc-reviewer.md +115 -0
- package/template/.claude/agents/performance-reviewer.md +138 -0
- package/template/.claude/agents/security-reviewer.md +163 -0
- package/template/.claude/agents/tester.md +3 -5
- package/template/.claude/hooks/README.md +39 -0
- package/template/.claude/hooks/block-dangerous-commands.sh +158 -0
- package/template/.claude/hooks/forge-active-skill-guard.sh +44 -0
- package/template/.claude/hooks/format-on-save.sh +95 -0
- package/template/.claude/hooks/protect-files.sh +101 -0
- package/template/.claude/hooks/scan-secrets.sh +87 -0
- package/template/.claude/hooks/tests/README.md +76 -0
- package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +376 -0
- package/template/.claude/hooks/tests/cases/protect-files.cases.json +222 -0
- package/template/.claude/hooks/tests/cases/scan-secrets.cases.json +218 -0
- package/template/.claude/hooks/tests/cases/warn-large-files.cases.json +146 -0
- package/template/.claude/hooks/tests/run.sh +118 -0
- package/template/.claude/hooks/warn-large-files.sh +71 -0
- package/template/.claude/rules/README.md +63 -0
- package/template/.claude/rules/agent-discipline.md +14 -0
- package/template/.claude/settings.json +69 -1
- package/template/.claude/skills/architecting/SKILL.md +2 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +37 -26
- package/template/.claude/skills/executing/SKILL.md +16 -0
- package/template/.claude/skills/forge/SKILL.md +15 -27
- package/template/.claude/skills/forge/desire-paths-review.md +37 -0
- package/template/.claude/skills/initializing/SKILL.md +4 -0
- package/template/.claude/skills/planning/SKILL.md +17 -3
- package/template/.claude/skills/reviewing/SKILL.md +2 -0
- package/template/.claude/skills/testing/SKILL.md +3 -3
- package/template/.claude/skills/verifying/SKILL.md +20 -9
- package/template/.forge/FORGE.md +36 -8
- package/template/.forge/migrations/0.20.0-nested-phase-layout.md +10 -2
- package/template/.forge/migrations/0.42.0-id-reservation.md +48 -0
- package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
- package/template/.forge/migrations/0.44.0-desire-paths.md +57 -0
- package/template/.forge/reservations.yml +31 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Forge — runs the project's formatter after the agent edits a file.
|
|
3
|
+
# PostToolUse hook for Edit|Write|MultiEdit. Always exits 0 — a formatting
|
|
4
|
+
# failure must never block subsequent work.
|
|
5
|
+
#
|
|
6
|
+
# "Most specific project config wins". A formatter only runs when BOTH the tool
|
|
7
|
+
# and the project's config for it are present, so this never fires against repos
|
|
8
|
+
# that haven't opted in.
|
|
9
|
+
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
# jq missing → silently skip. Never block work over formatting.
|
|
13
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
14
|
+
|
|
15
|
+
INPUT=$(cat)
|
|
16
|
+
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
|
|
17
|
+
[ -z "$FILE_PATH" ] && exit 0
|
|
18
|
+
[ ! -f "$FILE_PATH" ] && exit 0
|
|
19
|
+
|
|
20
|
+
EXT="${FILE_PATH##*.}"
|
|
21
|
+
EXT_LC=$(printf '%s' "$EXT" | tr '[:upper:]' '[:lower:]')
|
|
22
|
+
|
|
23
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
24
|
+
|
|
25
|
+
has_file() { [ -f "$REPO_ROOT/$1" ]; }
|
|
26
|
+
has_any() { for f in "$@"; do has_file "$f" && return 0; done; return 1; }
|
|
27
|
+
pyproject_has() { has_file "pyproject.toml" && grep -q "^\[$1\]" "$REPO_ROOT/pyproject.toml" 2>/dev/null; }
|
|
28
|
+
pkgjson_has_key() { has_file "package.json" && grep -q "\"$1\"" "$REPO_ROOT/package.json" 2>/dev/null; }
|
|
29
|
+
|
|
30
|
+
run() {
|
|
31
|
+
printf 'format-on-save: %s\n' "$*" >&2
|
|
32
|
+
"$@" >/dev/null 2>&1 || true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
case "$EXT_LC" in
|
|
36
|
+
# ── Python ────────────────────────────────────────────────────────────
|
|
37
|
+
py)
|
|
38
|
+
if command -v ruff >/dev/null 2>&1 \
|
|
39
|
+
&& ( has_any "ruff.toml" ".ruff.toml" || pyproject_has "tool.ruff" ); then
|
|
40
|
+
run ruff format "$FILE_PATH"
|
|
41
|
+
run ruff check --fix "$FILE_PATH"
|
|
42
|
+
elif command -v black >/dev/null 2>&1 && pyproject_has "tool.black"; then
|
|
43
|
+
run black --quiet "$FILE_PATH"
|
|
44
|
+
command -v isort >/dev/null 2>&1 && run isort --quiet "$FILE_PATH"
|
|
45
|
+
fi
|
|
46
|
+
;;
|
|
47
|
+
# ── PHP / Laravel ─────────────────────────────────────────────────────
|
|
48
|
+
php)
|
|
49
|
+
if [ -x "$REPO_ROOT/vendor/bin/pint" ]; then
|
|
50
|
+
run "$REPO_ROOT/vendor/bin/pint" "$FILE_PATH"
|
|
51
|
+
elif command -v php-cs-fixer >/dev/null 2>&1 && has_any ".php-cs-fixer.php" ".php-cs-fixer.dist.php"; then
|
|
52
|
+
run php-cs-fixer fix "$FILE_PATH"
|
|
53
|
+
fi
|
|
54
|
+
;;
|
|
55
|
+
# ── JS / TS / Vue / CSS / HTML / JSON / MD / YAML ─────────────────────
|
|
56
|
+
js|jsx|ts|tsx|vue|mjs|cjs|css|scss|sass|less|html|json|jsonc|md|mdx|yml|yaml)
|
|
57
|
+
if has_any "biome.json" "biome.jsonc" && [ -x "$REPO_ROOT/node_modules/.bin/biome" ]; then
|
|
58
|
+
run "$REPO_ROOT/node_modules/.bin/biome" format --write "$FILE_PATH"
|
|
59
|
+
run "$REPO_ROOT/node_modules/.bin/biome" check --write "$FILE_PATH"
|
|
60
|
+
else
|
|
61
|
+
if [ -x "$REPO_ROOT/node_modules/.bin/prettier" ] \
|
|
62
|
+
&& ( has_any ".prettierrc" ".prettierrc.json" ".prettierrc.js" ".prettierrc.cjs" ".prettierrc.mjs" ".prettierrc.yaml" ".prettierrc.yml" "prettier.config.js" "prettier.config.cjs" "prettier.config.mjs" \
|
|
63
|
+
|| pkgjson_has_key "prettier" ); then
|
|
64
|
+
run "$REPO_ROOT/node_modules/.bin/prettier" --write --log-level=silent "$FILE_PATH"
|
|
65
|
+
fi
|
|
66
|
+
case "$EXT_LC" in
|
|
67
|
+
js|jsx|ts|tsx|vue|mjs|cjs)
|
|
68
|
+
if [ -x "$REPO_ROOT/node_modules/.bin/eslint" ] \
|
|
69
|
+
&& ( has_any ".eslintrc" ".eslintrc.js" ".eslintrc.cjs" ".eslintrc.json" ".eslintrc.yml" ".eslintrc.yaml" "eslint.config.js" "eslint.config.mjs" "eslint.config.cjs" "eslint.config.ts" \
|
|
70
|
+
|| pkgjson_has_key "eslintConfig" ); then
|
|
71
|
+
run "$REPO_ROOT/node_modules/.bin/eslint" --fix "$FILE_PATH"
|
|
72
|
+
fi
|
|
73
|
+
;;
|
|
74
|
+
esac
|
|
75
|
+
fi
|
|
76
|
+
;;
|
|
77
|
+
# ── Rust ──────────────────────────────────────────────────────────────
|
|
78
|
+
rs)
|
|
79
|
+
command -v rustfmt >/dev/null 2>&1 && run rustfmt "$FILE_PATH"
|
|
80
|
+
;;
|
|
81
|
+
# ── Go ────────────────────────────────────────────────────────────────
|
|
82
|
+
go)
|
|
83
|
+
if command -v goimports >/dev/null 2>&1; then
|
|
84
|
+
run goimports -w "$FILE_PATH"
|
|
85
|
+
elif command -v gofmt >/dev/null 2>&1; then
|
|
86
|
+
run gofmt -w "$FILE_PATH"
|
|
87
|
+
fi
|
|
88
|
+
;;
|
|
89
|
+
# ── Shell ─────────────────────────────────────────────────────────────
|
|
90
|
+
sh|bash)
|
|
91
|
+
command -v shfmt >/dev/null 2>&1 && run shfmt -w "$FILE_PATH"
|
|
92
|
+
;;
|
|
93
|
+
esac
|
|
94
|
+
|
|
95
|
+
exit 0
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Forge safety guardrail — blocks edits to sensitive or generated files.
|
|
3
|
+
# PreToolUse hook for Edit|Write. Exit 2 = block. Exit 0 = allow.
|
|
4
|
+
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
|
|
7
|
+
emit() {
|
|
8
|
+
# $1 = decision (deny|ask) ; $2 = reason
|
|
9
|
+
local decision="$1"
|
|
10
|
+
local reason="${2//\"/\\\"}"
|
|
11
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":"%s"}}\n' "$decision" "$reason"
|
|
12
|
+
exit 2
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
16
|
+
emit deny "jq is required for file protection hooks but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
INPUT=$(cat)
|
|
20
|
+
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
|
|
21
|
+
[ -z "$FILE_PATH" ] && exit 0
|
|
22
|
+
|
|
23
|
+
BASENAME=$(basename -- "$FILE_PATH")
|
|
24
|
+
BASENAME_LC=$(printf '%s' "$BASENAME" | tr '[:upper:]' '[:lower:]')
|
|
25
|
+
PATH_LC=$(printf '%s' "$FILE_PATH" | tr '[:upper:]' '[:lower:]')
|
|
26
|
+
|
|
27
|
+
# Allowlist: template/example env files are meant to be checked in and edited.
|
|
28
|
+
case "$BASENAME_LC" in
|
|
29
|
+
.env.example|.env.sample|.env.dist|.env.template)
|
|
30
|
+
exit 0
|
|
31
|
+
;;
|
|
32
|
+
esac
|
|
33
|
+
|
|
34
|
+
# Protected basename patterns (matched case-insensitively).
|
|
35
|
+
PROTECTED_PATTERNS=(
|
|
36
|
+
".env"
|
|
37
|
+
".env.*"
|
|
38
|
+
"*.pem"
|
|
39
|
+
"*.key"
|
|
40
|
+
"*.crt"
|
|
41
|
+
"*.p12"
|
|
42
|
+
"*.pfx"
|
|
43
|
+
"id_rsa"
|
|
44
|
+
"id_ed25519"
|
|
45
|
+
"credentials.json"
|
|
46
|
+
".npmrc"
|
|
47
|
+
".pypirc"
|
|
48
|
+
".composer-auth.json"
|
|
49
|
+
"auth.json"
|
|
50
|
+
# Lock files — update through the package manager, never by hand.
|
|
51
|
+
"package-lock.json"
|
|
52
|
+
"yarn.lock"
|
|
53
|
+
"pnpm-lock.yaml"
|
|
54
|
+
"composer.lock"
|
|
55
|
+
"uv.lock"
|
|
56
|
+
"poetry.lock"
|
|
57
|
+
"Gemfile.lock"
|
|
58
|
+
"Cargo.lock"
|
|
59
|
+
"go.sum"
|
|
60
|
+
# Generated / minified
|
|
61
|
+
"*.gen.ts"
|
|
62
|
+
"*.generated.*"
|
|
63
|
+
"*.min.js"
|
|
64
|
+
"*.min.css"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
shopt -s nocasematch 2>/dev/null || true
|
|
68
|
+
for pattern in "${PROTECTED_PATTERNS[@]}"; do
|
|
69
|
+
case "$BASENAME_LC" in
|
|
70
|
+
$pattern)
|
|
71
|
+
emit deny "Protected file: $BASENAME matches pattern '$pattern'. Edit via the package manager or the appropriate secret store."
|
|
72
|
+
;;
|
|
73
|
+
esac
|
|
74
|
+
done
|
|
75
|
+
|
|
76
|
+
# Sensitive directories (lower-cased path for case-insensitive matches).
|
|
77
|
+
case "$PATH_LC" in
|
|
78
|
+
.git/*|*/.git/*)
|
|
79
|
+
emit deny "Cannot edit files inside .git/" ;;
|
|
80
|
+
secrets/*|*/secrets/*)
|
|
81
|
+
emit deny "Cannot edit files inside secrets/" ;;
|
|
82
|
+
.secrets/*|*/.secrets/*)
|
|
83
|
+
emit deny "Cannot edit files inside .secrets/" ;;
|
|
84
|
+
.env|.env.*|*/.env|*/.env.*)
|
|
85
|
+
emit deny "Cannot edit .env files" ;;
|
|
86
|
+
esac
|
|
87
|
+
|
|
88
|
+
# Framework-authoring files. In a normal project these are re-synced via
|
|
89
|
+
# /upgrading, never hand-edited — so we block hooks and prompt on settings.
|
|
90
|
+
# FORGE_ALLOW_HOOK_EDITS=1 lifts both, set only in the Forge framework repo
|
|
91
|
+
# itself (where authoring hooks and settings IS the work).
|
|
92
|
+
if [ -z "${FORGE_ALLOW_HOOK_EDITS:-}" ]; then
|
|
93
|
+
case "$PATH_LC" in
|
|
94
|
+
.claude/hooks/*|*/.claude/hooks/*)
|
|
95
|
+
emit deny "Cannot edit hook scripts — these enforce safety boundaries. Update them in the framework repo and re-sync (/upgrading)." ;;
|
|
96
|
+
.claude/settings.json|*/.claude/settings.json|.claude/settings.local.json|*/.claude/settings.local.json)
|
|
97
|
+
emit ask "Editing settings.json — this controls permissions and hooks. Confirm this change." ;;
|
|
98
|
+
esac
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
exit 0
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Forge safety guardrail — scans file content for accidental secrets before writing.
|
|
3
|
+
# PreToolUse hook for Edit|Write. Exit 2 = block (decision "ask" so the user can
|
|
4
|
+
# override for genuine test fixtures). Exit 0 = allow.
|
|
5
|
+
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
|
|
8
|
+
emit_deny() {
|
|
9
|
+
local reason="${1//\"/\\\"}"
|
|
10
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
|
|
11
|
+
exit 2
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
15
|
+
emit_deny "jq is required for secret scanning but is not installed. Install with: brew install jq (macOS) or apt install jq (Linux)."
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat)
|
|
19
|
+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
|
20
|
+
|
|
21
|
+
if [ "$TOOL_NAME" = "Write" ]; then
|
|
22
|
+
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
|
|
23
|
+
elif [ "$TOOL_NAME" = "Edit" ]; then
|
|
24
|
+
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
|
|
25
|
+
else
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
[ -z "$CONTENT" ] && exit 0
|
|
30
|
+
|
|
31
|
+
MATCHES=""
|
|
32
|
+
|
|
33
|
+
# AWS Access Key IDs
|
|
34
|
+
if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
|
|
35
|
+
MATCHES="$MATCHES AWS access key (AKIA...);"
|
|
36
|
+
fi
|
|
37
|
+
# AWS Secret Access Keys (40 chars base64 after a key assignment)
|
|
38
|
+
if echo "$CONTENT" | grep -qiE '(aws_secret_access_key|secret_key)[[:space:]]*[=:][[:space:]]*["\x27]?[A-Za-z0-9/+=]{40}'; then
|
|
39
|
+
MATCHES="$MATCHES AWS secret key;"
|
|
40
|
+
fi
|
|
41
|
+
# GitHub tokens (PAT, OAuth, App)
|
|
42
|
+
if echo "$CONTENT" | grep -qE '(ghp_|gho_|ghs_|ghr_|github_pat_)[a-zA-Z0-9_]{20,}'; then
|
|
43
|
+
MATCHES="$MATCHES GitHub token;"
|
|
44
|
+
fi
|
|
45
|
+
# OpenAI / Anthropic / Stripe style keys (sk-...)
|
|
46
|
+
if echo "$CONTENT" | grep -qE 'sk-[a-zA-Z0-9]{20,}'; then
|
|
47
|
+
MATCHES="$MATCHES API key (sk-...);"
|
|
48
|
+
fi
|
|
49
|
+
# Anthropic-specific keys
|
|
50
|
+
if echo "$CONTENT" | grep -qE 'sk-ant-[a-zA-Z0-9_-]{20,}'; then
|
|
51
|
+
MATCHES="$MATCHES Anthropic API key;"
|
|
52
|
+
fi
|
|
53
|
+
# Slack tokens
|
|
54
|
+
if echo "$CONTENT" | grep -qE 'xox[bpras]-[0-9a-zA-Z-]{10,}'; then
|
|
55
|
+
MATCHES="$MATCHES Slack token;"
|
|
56
|
+
fi
|
|
57
|
+
# Google API keys
|
|
58
|
+
if echo "$CONTENT" | grep -qE 'AIza[0-9A-Za-z_-]{35}'; then
|
|
59
|
+
MATCHES="$MATCHES Google API key;"
|
|
60
|
+
fi
|
|
61
|
+
# Private key blocks
|
|
62
|
+
if echo "$CONTENT" | grep -qE -- '-----BEGIN[[:space:]]+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then
|
|
63
|
+
MATCHES="$MATCHES private key block;"
|
|
64
|
+
fi
|
|
65
|
+
# Connection strings with embedded credentials
|
|
66
|
+
if echo "$CONTENT" | grep -qE '(mongodb|postgres|postgresql|mysql|redis|amqp|smtp|mssql)(\+[a-z]+)?://[^:[:space:]]+:[^@[:space:]]+@'; then
|
|
67
|
+
MATCHES="$MATCHES connection string with credentials;"
|
|
68
|
+
fi
|
|
69
|
+
# Laravel APP_KEY with a real base64 value (not a placeholder)
|
|
70
|
+
if echo "$CONTENT" | grep -qE 'APP_KEY[[:space:]]*=[[:space:]]*base64:[A-Za-z0-9+/=]{40,}'; then
|
|
71
|
+
MATCHES="$MATCHES Laravel APP_KEY;"
|
|
72
|
+
fi
|
|
73
|
+
# Generic password/secret/token assignments with literal string values.
|
|
74
|
+
# Excludes env var references (process.env, os.environ, getenv, ${...}, ENV[, env(, config()).
|
|
75
|
+
if echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27][^"\x27]{8,}["\x27]' && \
|
|
76
|
+
! echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27]?(process\.env|os\.environ|getenv|\$\{|ENV\[|env\(|config\()'; then
|
|
77
|
+
MATCHES="$MATCHES hardcoded credential;"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
if [ -n "$MATCHES" ]; then
|
|
81
|
+
# "ask" not "deny" — warn but let the user override (could be a fixture).
|
|
82
|
+
REASON="Possible secret detected in content:$MATCHES Review carefully before allowing."
|
|
83
|
+
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
84
|
+
exit 2
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
exit 0
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Hook tests
|
|
2
|
+
|
|
3
|
+
Data-driven test harness for the Claude Code hooks in `.claude/hooks/`. Cases live in JSON files, the driver pipes each input to the hook and checks the exit code and stdout.
|
|
4
|
+
|
|
5
|
+
## Running
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# All hooks
|
|
9
|
+
.claude/hooks/tests/run.sh
|
|
10
|
+
|
|
11
|
+
# One hook
|
|
12
|
+
.claude/hooks/tests/run.sh protect-files
|
|
13
|
+
.claude/hooks/tests/run.sh block-dangerous-commands
|
|
14
|
+
.claude/hooks/tests/run.sh warn-large-files
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires `jq`. Install with `brew install jq` (macOS) or `apt install jq` (Linux).
|
|
18
|
+
|
|
19
|
+
## Layout
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
tests/
|
|
23
|
+
run.sh # driver
|
|
24
|
+
cases/
|
|
25
|
+
<hook-name>.cases.json # test cases for .claude/hooks/<hook-name>.sh
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The driver matches cases to hook scripts by filename: `cases/protect-files.cases.json` runs against `.claude/hooks/protect-files.sh`. If the hook script is missing or not executable, the test file is skipped.
|
|
29
|
+
|
|
30
|
+
## Case schema
|
|
31
|
+
|
|
32
|
+
Each case file is a JSON array of objects:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"name": "denies editing .env at repo root",
|
|
37
|
+
"input": { "tool_name": "Edit", "tool_input": { "file_path": ".env" } },
|
|
38
|
+
"expect_exit": 2,
|
|
39
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\"",
|
|
40
|
+
"expect_stdout_not_contains": "allow"
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Field | Required | Meaning |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `name` | yes | Human-readable test label |
|
|
47
|
+
| `input` | yes | JSON piped to the hook on stdin — matches Claude Code's hook input format |
|
|
48
|
+
| `expect_exit` | no (default 0) | Expected process exit code. `2` = permission denied, `0` = allowed |
|
|
49
|
+
| `expect_stdout_contains` | no | Substring that must appear in stdout (combined with stderr) |
|
|
50
|
+
| `expect_stdout_not_contains` | no | Substring that must NOT appear |
|
|
51
|
+
|
|
52
|
+
The driver combines stdout and stderr, so a hook that writes errors to stderr is still matched against `expect_stdout_contains`.
|
|
53
|
+
|
|
54
|
+
## Adding a new case
|
|
55
|
+
|
|
56
|
+
1. Pick the right case file (`cases/<hook>.cases.json`) — or create one if you are testing a new hook.
|
|
57
|
+
2. Append a new object to the array. Keep the name as a sentence that reads like a spec: `"denies rm -rf $HOME"`, `"allows artisan migrate"`.
|
|
58
|
+
3. Run the hook-specific filter to verify: `.claude/hooks/tests/run.sh <hook>`.
|
|
59
|
+
|
|
60
|
+
## Adding a new hook
|
|
61
|
+
|
|
62
|
+
1. Write the hook at `.claude/hooks/<hook-name>.sh` and `chmod +x` it.
|
|
63
|
+
2. Create `cases/<hook-name>.cases.json` with at least one deny case, one allow case, and one no-op case (missing input field).
|
|
64
|
+
3. Run `.claude/hooks/tests/run.sh <hook-name>`.
|
|
65
|
+
|
|
66
|
+
## What good coverage looks like
|
|
67
|
+
|
|
68
|
+
For a `deny`/`allow` style hook, every case file should include:
|
|
69
|
+
|
|
70
|
+
- At least one **deny** per distinct rule the hook implements (so the hook script cannot be trivially gutted without turning tests red).
|
|
71
|
+
- At least one **allow** that sits right next to a deny path (e.g. `git push origin main` denies, `git push origin feature/foo` allows) — proves the pattern is specific, not blanket.
|
|
72
|
+
- One **no-op** case where the hook input does not match the shape the hook expects (e.g. `file_path` missing, `command` empty) — proves the hook fails open for irrelevant events.
|
|
73
|
+
|
|
74
|
+
## Why this lives here
|
|
75
|
+
|
|
76
|
+
Hooks run on every tool call the agent makes. If they regress silently, either the agent starts bypassing real protections, or innocent edits start getting blocked and the hooks get disabled in frustration. A small test harness catches both before they land.
|