forge-orkes 0.42.0 → 0.46.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.
Files changed (35) hide show
  1. package/bin/create-forge.js +138 -1
  2. package/package.json +1 -1
  3. package/template/.claude/agents/doc-reviewer.md +115 -0
  4. package/template/.claude/agents/performance-reviewer.md +138 -0
  5. package/template/.claude/agents/security-reviewer.md +163 -0
  6. package/template/.claude/hooks/README.md +39 -0
  7. package/template/.claude/hooks/block-dangerous-commands.sh +158 -0
  8. package/template/.claude/hooks/forge-active-skill-guard.sh +44 -0
  9. package/template/.claude/hooks/forge-reserve.sh +162 -0
  10. package/template/.claude/hooks/format-on-save.sh +95 -0
  11. package/template/.claude/hooks/protect-files.sh +101 -0
  12. package/template/.claude/hooks/scan-secrets.sh +87 -0
  13. package/template/.claude/hooks/tests/README.md +76 -0
  14. package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +376 -0
  15. package/template/.claude/hooks/tests/cases/protect-files.cases.json +222 -0
  16. package/template/.claude/hooks/tests/cases/scan-secrets.cases.json +218 -0
  17. package/template/.claude/hooks/tests/cases/warn-large-files.cases.json +146 -0
  18. package/template/.claude/hooks/tests/forge-reserve.test.sh +121 -0
  19. package/template/.claude/hooks/tests/run.sh +118 -0
  20. package/template/.claude/hooks/warn-large-files.sh +71 -0
  21. package/template/.claude/rules/README.md +63 -0
  22. package/template/.claude/rules/agent-discipline.md +14 -0
  23. package/template/.claude/settings.json +69 -1
  24. package/template/.claude/skills/architecting/SKILL.md +1 -1
  25. package/template/.claude/skills/chief-of-staff/SKILL.md +14 -0
  26. package/template/.claude/skills/executing/SKILL.md +16 -0
  27. package/template/.claude/skills/forge/SKILL.md +12 -1
  28. package/template/.claude/skills/initializing/SKILL.md +4 -0
  29. package/template/.claude/skills/planning/SKILL.md +14 -2
  30. package/template/.claude/skills/reviewing/SKILL.md +2 -0
  31. package/template/.claude/skills/verifying/SKILL.md +19 -8
  32. package/template/.forge/FORGE.md +32 -15
  33. package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
  34. package/template/.forge/migrations/0.44.0-desire-paths.md +57 -0
  35. package/template/.forge/migrations/0.46.0-id-reservation-ledger.md +44 -0
@@ -0,0 +1,218 @@
1
+ [
2
+ {
3
+ "name": "flags AWS access key ID on Write",
4
+ "input": {
5
+ "tool_name": "Write",
6
+ "tool_input": {
7
+ "file_path": "src/config.py",
8
+ "content": "AWS_KEY = 'AKIAIOSFODNN7EXAMPLE'"
9
+ }
10
+ },
11
+ "expect_exit": 2,
12
+ "expect_stdout_contains": "\"permissionDecision\":\"ask\""
13
+ },
14
+ {
15
+ "name": "flags AWS secret access key assignment",
16
+ "input": {
17
+ "tool_name": "Write",
18
+ "tool_input": {
19
+ "file_path": "src/config.py",
20
+ "content": "aws_secret_access_key = \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\""
21
+ }
22
+ },
23
+ "expect_exit": 2,
24
+ "expect_stdout_contains": "AWS secret key"
25
+ },
26
+ {
27
+ "name": "flags GitHub personal access token",
28
+ "input": {
29
+ "tool_name": "Write",
30
+ "tool_input": {
31
+ "file_path": "src/config.py",
32
+ "content": "GH_TOKEN = 'ghp_abcdefghijklmnopqrstuvwxyz0123456789'"
33
+ }
34
+ },
35
+ "expect_exit": 2,
36
+ "expect_stdout_contains": "GitHub token"
37
+ },
38
+ {
39
+ "name": "flags Anthropic API key",
40
+ "input": {
41
+ "tool_name": "Write",
42
+ "tool_input": {
43
+ "file_path": "src/config.py",
44
+ "content": "ANTHROPIC_API_KEY = 'sk-ant-abcdefghijklmnopqrstuv'"
45
+ }
46
+ },
47
+ "expect_exit": 2,
48
+ "expect_stdout_contains": "Anthropic API key"
49
+ },
50
+ {
51
+ "name": "flags Slack bot token",
52
+ "input": {
53
+ "tool_name": "Write",
54
+ "tool_input": {
55
+ "file_path": "src/config.py",
56
+ "content": "SLACK_TOKEN = 'xoxb-1234567890-abcdefghijk'"
57
+ }
58
+ },
59
+ "expect_exit": 2,
60
+ "expect_stdout_contains": "Slack token"
61
+ },
62
+ {
63
+ "name": "flags a private key block",
64
+ "input": {
65
+ "tool_name": "Write",
66
+ "tool_input": {
67
+ "file_path": "secrets/key.pem",
68
+ "content": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
69
+ }
70
+ },
71
+ "expect_exit": 2,
72
+ "expect_stdout_contains": "private key block"
73
+ },
74
+ {
75
+ "name": "flags Postgres connection string with embedded credentials",
76
+ "input": {
77
+ "tool_name": "Write",
78
+ "tool_input": {
79
+ "file_path": ".env.example",
80
+ "content": "DATABASE_URL=postgres://admin:s3cretP@ss@db.example.com:5432/app"
81
+ }
82
+ },
83
+ "expect_exit": 2,
84
+ "expect_stdout_contains": "connection string with credentials"
85
+ },
86
+ {
87
+ "name": "flags Laravel APP_KEY with real base64 value",
88
+ "input": {
89
+ "tool_name": "Write",
90
+ "tool_input": {
91
+ "file_path": ".env",
92
+ "content": "APP_KEY=base64:IAm40CharsOfBase64EncodedAppKeyForLaravelApp="
93
+ }
94
+ },
95
+ "expect_exit": 2,
96
+ "expect_stdout_contains": "Laravel APP_KEY"
97
+ },
98
+ {
99
+ "name": "flags hardcoded password assignment with literal value",
100
+ "input": {
101
+ "tool_name": "Write",
102
+ "tool_input": {
103
+ "file_path": "app/Services/MailService.php",
104
+ "content": "$password = \"realPassword123\";"
105
+ }
106
+ },
107
+ "expect_exit": 2,
108
+ "expect_stdout_contains": "hardcoded credential"
109
+ },
110
+ {
111
+ "name": "flags secret found inside an Edit new_string",
112
+ "input": {
113
+ "tool_name": "Edit",
114
+ "tool_input": {
115
+ "file_path": "src/config.py",
116
+ "old_string": "TOKEN = ''",
117
+ "new_string": "TOKEN = 'ghp_abcdefghijklmnopqrstuvwxyz0123456789'"
118
+ }
119
+ },
120
+ "expect_exit": 2,
121
+ "expect_stdout_contains": "GitHub token"
122
+ },
123
+ {
124
+ "name": "allows password field that references process.env",
125
+ "input": {
126
+ "tool_name": "Write",
127
+ "tool_input": {
128
+ "file_path": "src/db.js",
129
+ "content": "const password = process.env.DB_PASSWORD;"
130
+ }
131
+ },
132
+ "expect_exit": 0,
133
+ "expect_stdout_not_contains": "permissionDecision"
134
+ },
135
+ {
136
+ "name": "allows password field that uses Laravel env() helper",
137
+ "input": {
138
+ "tool_name": "Write",
139
+ "tool_input": {
140
+ "file_path": "config/database.php",
141
+ "content": "'password' => env('DB_PASSWORD', ''),"
142
+ }
143
+ },
144
+ "expect_exit": 0,
145
+ "expect_stdout_not_contains": "permissionDecision"
146
+ },
147
+ {
148
+ "name": "allows password mentioned only in a comment",
149
+ "input": {
150
+ "tool_name": "Write",
151
+ "tool_input": {
152
+ "file_path": "app/Services/MailService.php",
153
+ "content": "// Remember to set the MAIL_PASSWORD in .env\n$mailer = new Mailer();"
154
+ }
155
+ },
156
+ "expect_exit": 0,
157
+ "expect_stdout_not_contains": "permissionDecision"
158
+ },
159
+ {
160
+ "name": "allows empty content",
161
+ "input": {
162
+ "tool_name": "Write",
163
+ "tool_input": {
164
+ "file_path": "src/empty.py",
165
+ "content": ""
166
+ }
167
+ },
168
+ "expect_exit": 0,
169
+ "expect_stdout_not_contains": "permissionDecision"
170
+ },
171
+ {
172
+ "name": "ignores non-Write/Edit tools",
173
+ "input": {
174
+ "tool_name": "Bash",
175
+ "tool_input": {
176
+ "command": "echo ghp_abcdefghijklmnopqrstuvwxyz0123456789"
177
+ }
178
+ },
179
+ "expect_exit": 0,
180
+ "expect_stdout_not_contains": "permissionDecision"
181
+ },
182
+ {
183
+ "name": "allows an APP_KEY placeholder shorter than 40 chars",
184
+ "input": {
185
+ "tool_name": "Write",
186
+ "tool_input": {
187
+ "file_path": ".env.example",
188
+ "content": "APP_KEY=base64:TODO_RUN_ARTISAN_KEY_GENERATE"
189
+ }
190
+ },
191
+ "expect_exit": 0,
192
+ "expect_stdout_not_contains": "permissionDecision"
193
+ },
194
+ {
195
+ "name": "allows a regular Python file with no secrets",
196
+ "input": {
197
+ "tool_name": "Write",
198
+ "tool_input": {
199
+ "file_path": "src/utils.py",
200
+ "content": "def add(a: int, b: int) -> int:\n return a + b\n"
201
+ }
202
+ },
203
+ "expect_exit": 0,
204
+ "expect_stdout_not_contains": "permissionDecision"
205
+ },
206
+ {
207
+ "name": "flags a Google API key",
208
+ "input": {
209
+ "tool_name": "Write",
210
+ "tool_input": {
211
+ "file_path": "config.js",
212
+ "content": "const key = \"AIzaSyD-1234567890abcdefGHIJKLMNOPqrstuv\""
213
+ }
214
+ },
215
+ "expect_exit": 2,
216
+ "expect_stdout_contains": "Google API key"
217
+ }
218
+ ]
@@ -0,0 +1,146 @@
1
+ [
2
+ {
3
+ "name": "denies writing into node_modules",
4
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "node_modules/react/index.js" } },
5
+ "expect_exit": 2,
6
+ "expect_stdout_contains": "node_modules"
7
+ },
8
+ {
9
+ "name": "denies writing into nested node_modules",
10
+ "input": { "tool_name": "Edit", "tool_input": { "file_path": "apps/web/node_modules/foo/index.js" } },
11
+ "expect_exit": 2,
12
+ "expect_stdout_contains": "node_modules"
13
+ },
14
+ {
15
+ "name": "denies writing into dist",
16
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "dist/bundle.js" } },
17
+ "expect_exit": 2,
18
+ "expect_stdout_contains": "build output"
19
+ },
20
+ {
21
+ "name": "denies writing into build",
22
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "build/app.js" } },
23
+ "expect_exit": 2,
24
+ "expect_stdout_contains": "build output"
25
+ },
26
+ {
27
+ "name": "denies writing into vendor",
28
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "vendor/laravel/framework/src/foo.php" } },
29
+ "expect_exit": 2,
30
+ "expect_stdout_contains": "vendor"
31
+ },
32
+ {
33
+ "name": "denies writing into storage/framework",
34
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "storage/framework/views/cached.php" } },
35
+ "expect_exit": 2,
36
+ "expect_stdout_contains": "framework caches"
37
+ },
38
+ {
39
+ "name": "denies writing into bootstrap/cache",
40
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "bootstrap/cache/config.php" } },
41
+ "expect_exit": 2,
42
+ "expect_stdout_contains": "framework caches"
43
+ },
44
+ {
45
+ "name": "denies writing into storage/logs",
46
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "storage/logs/laravel.log" } },
47
+ "expect_exit": 2,
48
+ "expect_stdout_contains": "runtime logs"
49
+ },
50
+ {
51
+ "name": "denies writing into __pycache__",
52
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "app/__pycache__/module.cpython-311.pyc" } },
53
+ "expect_exit": 2,
54
+ "expect_stdout_contains": "__pycache__"
55
+ },
56
+ {
57
+ "name": "denies writing into .venv",
58
+ "input": { "tool_name": "Write", "tool_input": { "file_path": ".venv/lib/python3.11/site-packages/foo.py" } },
59
+ "expect_exit": 2,
60
+ "expect_stdout_contains": "virtual environment"
61
+ },
62
+ {
63
+ "name": "denies writing into .pytest_cache",
64
+ "input": { "tool_name": "Write", "tool_input": { "file_path": ".pytest_cache/v/cache/lastfailed" } },
65
+ "expect_exit": 2,
66
+ "expect_stdout_contains": "Python tool caches"
67
+ },
68
+ {
69
+ "name": "denies writing into coverage",
70
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "coverage/lcov.info" } },
71
+ "expect_exit": 2,
72
+ "expect_stdout_contains": "coverage"
73
+ },
74
+ {
75
+ "name": "denies writing a .DS_Store",
76
+ "input": { "tool_name": "Write", "tool_input": { "file_path": ".DS_Store" } },
77
+ "expect_exit": 2,
78
+ "expect_stdout_contains": ".DS_Store"
79
+ },
80
+ {
81
+ "name": "denies writing a .so binary",
82
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "lib/native.so" } },
83
+ "expect_exit": 2,
84
+ "expect_stdout_contains": "binary files"
85
+ },
86
+ {
87
+ "name": "denies writing a .exe",
88
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "bin/tool.exe" } },
89
+ "expect_exit": 2,
90
+ "expect_stdout_contains": "binary files"
91
+ },
92
+ {
93
+ "name": "denies writing a .zip archive",
94
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "release/bundle.zip" } },
95
+ "expect_exit": 2,
96
+ "expect_stdout_contains": "archive"
97
+ },
98
+ {
99
+ "name": "denies writing a .phar",
100
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "tools/composer.phar" } },
101
+ "expect_exit": 2,
102
+ "expect_stdout_contains": "archive"
103
+ },
104
+ {
105
+ "name": "denies writing an mp4",
106
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "assets/intro.mp4" } },
107
+ "expect_exit": 2,
108
+ "expect_stdout_contains": "media files"
109
+ },
110
+ {
111
+ "name": "denies writing a .pyc",
112
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "app/module.pyc" } },
113
+ "expect_exit": 2,
114
+ "expect_stdout_contains": "bytecode"
115
+ },
116
+ {
117
+ "name": "allows writing a regular Python file",
118
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "app/domains/service.py" } },
119
+ "expect_exit": 0,
120
+ "expect_stdout_not_contains": "permissionDecision"
121
+ },
122
+ {
123
+ "name": "allows writing a regular PHP file",
124
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "app/Cerebro/Domains/Services/DomainService.php" } },
125
+ "expect_exit": 0,
126
+ "expect_stdout_not_contains": "permissionDecision"
127
+ },
128
+ {
129
+ "name": "allows writing a Vue file in resources",
130
+ "input": { "tool_name": "Write", "tool_input": { "file_path": "resources/js/pages/domains/Index.vue" } },
131
+ "expect_exit": 0,
132
+ "expect_stdout_not_contains": "permissionDecision"
133
+ },
134
+ {
135
+ "name": "allows writing a .gitignore",
136
+ "input": { "tool_name": "Write", "tool_input": { "file_path": ".gitignore" } },
137
+ "expect_exit": 0,
138
+ "expect_stdout_not_contains": "permissionDecision"
139
+ },
140
+ {
141
+ "name": "no-op when file_path is missing",
142
+ "input": { "tool_name": "Bash", "tool_input": { "command": "ls" } },
143
+ "expect_exit": 0,
144
+ "expect_stdout_not_contains": "permissionDecision"
145
+ }
146
+ ]
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env sh
2
+ # Self-contained unit test for .claude/hooks/forge-reserve.sh (ADR-021).
3
+ #
4
+ # Needs no network and no fixtures beyond a temp dir. Each case builds a throwaway
5
+ # git repo, seeds ids, and asserts the helper's stdout + file side effects. Run:
6
+ # ./.claude/hooks/tests/forge-reserve.test.sh
7
+ # Exits 0 iff every case passes; non-zero on any failure. Self-cleans on exit.
8
+ set -u
9
+
10
+ # Resolve the helper from THIS script's own location so the test works from any cwd.
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ HELPER="$SCRIPT_DIR/../forge-reserve.sh"
13
+
14
+ [ -x "$HELPER" ] || { printf 'FATAL: helper not executable: %s\n' "$HELPER" >&2; exit 1; }
15
+
16
+ # One temp root; every throwaway repo lives under it; single cleanup.
17
+ ROOT="$(mktemp -d)"
18
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
19
+
20
+ PASSED=0
21
+ FAILED=0
22
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
23
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
24
+ check() { # desc, actual, expected
25
+ if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi
26
+ }
27
+
28
+ # Fresh repo on branch `main` (symbolic-ref sets the unborn branch name portably,
29
+ # no git ≥2.28 -b flag needed). Prints its path.
30
+ new_repo() {
31
+ d="$ROOT/repo.$1"
32
+ mkdir -p "$d"
33
+ git -C "$d" init -q
34
+ git -C "$d" symbolic-ref HEAD refs/heads/main
35
+ git -C "$d" config user.email t@example.com
36
+ git -C "$d" config user.name tester
37
+ printf '%s\n' "$d"
38
+ }
39
+
40
+ # --- Case 1: allocation from in-tree ADR ------------------------------------
41
+ d="$(new_repo case1)"; cd "$d"
42
+ mkdir -p docs/decisions
43
+ : > docs/decisions/ADR-005-something.md
44
+ git add -A && git commit -qm init
45
+ got="$("$HELPER" adr --milestone m-x)"
46
+ check "case1 in-tree ADR-005 -> ADR-006" "$got" "ADR-006"
47
+
48
+ # --- Case 2: ledger beats tree (uncommitted sibling — the ADR-016-blind case) -
49
+ d="$(new_repo case2)"; cd "$d"
50
+ mkdir -p docs/decisions
51
+ : > docs/decisions/ADR-005-something.md
52
+ git add -A && git commit -qm init
53
+ mkdir -p .git/forge
54
+ printf 'adr\tADR-050\tm-x\t2026-01-01T00:00:00Z\n' > .git/forge/id-reservations
55
+ got="$("$HELPER" adr --milestone m-x)"
56
+ check "case2 ledger(ADR-050) beats tree(ADR-005) -> ADR-051" "$got" "ADR-051"
57
+
58
+ # --- Case 3: main:reservations.yml floor (committed, then removed from tree) --
59
+ d="$(new_repo case3)"; cd "$d"
60
+ mkdir -p .forge
61
+ cat > .forge/reservations.yml <<'YML'
62
+ reservations:
63
+ - kind: fr
64
+ id: FR-200
65
+ milestone: m-x
66
+ reserved_at: "2026-01-01"
67
+ summary: "seed"
68
+ YML
69
+ git add -A && git commit -qm init
70
+ rm .forge/reservations.yml # gone from the working tree; survives only on main
71
+ got="$("$HELPER" fr --milestone m-x)"
72
+ check "case3 main floor FR-200 -> FR-201" "$got" "FR-201"
73
+
74
+ # --- Case 4: dual-write side effects + no commit made by the helper ----------
75
+ d="$(new_repo case4)"; cd "$d"
76
+ git commit --allow-empty -qm init
77
+ ledger=".git/forge/id-reservations"
78
+ before=0; [ -f "$ledger" ] && before="$(wc -l < "$ledger" | tr -d ' ')"
79
+ got="$("$HELPER" nfr --milestone m-x --summary "dual write check")"
80
+ after="$(wc -l < "$ledger" | tr -d ' ')"
81
+ check "case4 ledger grew by exactly 1" "$((after - before))" "1"
82
+ if grep -q "id: $got" .forge/reservations.yml; then
83
+ pass "case4 reservations.yml has a $got block"
84
+ else
85
+ fail "case4 reservations.yml missing $got block"
86
+ fi
87
+ check "case4 no new commit (HEAD count still 1)" "$(git rev-list --count HEAD)" "1"
88
+ if [ -n "$(git status --porcelain .forge/reservations.yml)" ]; then
89
+ pass "case4 reservations.yml is uncommitted (dirty)"
90
+ else
91
+ fail "case4 reservations.yml unexpectedly clean"
92
+ fi
93
+
94
+ # --- Case 5: unknown kind — non-zero exit, empty stdout, nothing written -----
95
+ d="$(new_repo case5)"; cd "$d"
96
+ git commit --allow-empty -qm init
97
+ out="$("$HELPER" zzz --milestone m-x 2>/dev/null)"; rc=$?
98
+ check "case5 unknown kind exits non-zero" "$( [ "$rc" -ne 0 ] && echo yes || echo no )" "yes"
99
+ check "case5 unknown kind prints nothing on stdout" "$out" ""
100
+ [ ! -f .git/forge/id-reservations ] && pass "case5 ledger not created" || fail "case5 ledger was created"
101
+ [ ! -f .forge/reservations.yml ] && pass "case5 reservations.yml not created" || fail "case5 reservations.yml was created"
102
+
103
+ # --- Case 6: N-way concurrency yields N distinct ids, N ledger lines ---------
104
+ d="$(new_repo case6)"; cd "$d"
105
+ git commit --allow-empty -qm init
106
+ outdir="$d/outs"; mkdir -p "$outdir"
107
+ N=8
108
+ i=1
109
+ while [ "$i" -le "$N" ]; do
110
+ ( "$HELPER" fr --milestone m-x > "$outdir/out.$i" 2>/dev/null ) &
111
+ i=$((i + 1))
112
+ done
113
+ wait
114
+ distinct="$(cat "$outdir"/out.* | sort -u | grep -c .)"
115
+ check "case6 $N concurrent runs -> $N distinct ids" "$distinct" "$N"
116
+ ledger_lines="$(grep -c '^fr ' .git/forge/id-reservations)"
117
+ check "case6 $N distinct ledger fr lines" "$ledger_lines" "$N"
118
+
119
+ # --- Report -----------------------------------------------------------------
120
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
121
+ [ "$FAILED" -eq 0 ]
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env bash
2
+ # Runs all hook test cases and reports pass/fail.
3
+ #
4
+ # Usage:
5
+ # .claude/hooks/tests/run.sh # run all hooks
6
+ # .claude/hooks/tests/run.sh protect-files # run one hook
7
+ #
8
+ # Case files live in .claude/hooks/tests/cases/<hook>.cases.json.
9
+ # Each case is an object with: name, input, expect_exit, expect_stdout_contains,
10
+ # expect_stdout_not_contains. See README.md for the full schema.
11
+
12
+ set -uo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15
+ HOOKS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
16
+ CASES_DIR="$SCRIPT_DIR/cases"
17
+
18
+ # Hermetic run: cases assert default behavior, so neutralise the env knobs that
19
+ # a maintainer's session may set (Forge's own dev repo exports both). Without
20
+ # this, FORGE_ALLOW_HOOK_EDITS=1 lifts protect-files' hook/settings guards and an
21
+ # empty CLAUDE_PROTECTED_BRANCHES disables the push checks — turning real cases red.
22
+ unset FORGE_ALLOW_HOOK_EDITS
23
+ unset CLAUDE_PROTECTED_BRANCHES
24
+
25
+ if ! command -v jq >/dev/null 2>&1; then
26
+ printf 'jq is required to run hook tests. Install with: brew install jq (macOS) or apt install jq (Linux).\n' >&2
27
+ exit 1
28
+ fi
29
+
30
+ # Optional single-hook filter.
31
+ FILTER="${1:-}"
32
+
33
+ PASSED=0
34
+ FAILED=0
35
+ SKIPPED=0
36
+ FAILURES=()
37
+
38
+ run_case() {
39
+ local hook_script="$1" case_json="$2"
40
+ local name input expect_exit expect_contains expect_not_contains
41
+ name=$(printf '%s' "$case_json" | jq -r '.name')
42
+ input=$(printf '%s' "$case_json" | jq -c '.input')
43
+ expect_exit=$(printf '%s' "$case_json" | jq -r '.expect_exit // 0')
44
+ expect_contains=$(printf '%s' "$case_json" | jq -r '.expect_stdout_contains // ""')
45
+ expect_not_contains=$(printf '%s' "$case_json" | jq -r '.expect_stdout_not_contains // ""')
46
+
47
+ local output rc
48
+ output=$(printf '%s' "$input" | "$hook_script" 2>&1)
49
+ rc=$?
50
+
51
+ local reasons=()
52
+ if [ "$rc" -ne "$expect_exit" ]; then
53
+ reasons+=("exit $rc, expected $expect_exit")
54
+ fi
55
+ if [ -n "$expect_contains" ] && ! printf '%s' "$output" | grep -qF -- "$expect_contains"; then
56
+ reasons+=("stdout missing: $expect_contains")
57
+ fi
58
+ if [ -n "$expect_not_contains" ] && printf '%s' "$output" | grep -qF -- "$expect_not_contains"; then
59
+ reasons+=("stdout should not contain: $expect_not_contains")
60
+ fi
61
+
62
+ if [ "${#reasons[@]}" -eq 0 ]; then
63
+ printf ' [PASS] %s\n' "$name"
64
+ PASSED=$((PASSED + 1))
65
+ else
66
+ printf ' [FAIL] %s\n' "$name"
67
+ for reason in "${reasons[@]}"; do
68
+ printf ' %s\n' "$reason"
69
+ done
70
+ if [ -n "$output" ]; then
71
+ printf ' output: %s\n' "$output" | head -3
72
+ fi
73
+ FAILED=$((FAILED + 1))
74
+ FAILURES+=("$name")
75
+ fi
76
+ }
77
+
78
+ for case_file in "$CASES_DIR"/*.cases.json; do
79
+ [ -f "$case_file" ] || continue
80
+
81
+ hook_name=$(basename "$case_file" .cases.json)
82
+
83
+ if [ -n "$FILTER" ] && [ "$hook_name" != "$FILTER" ]; then
84
+ continue
85
+ fi
86
+
87
+ hook_script="$HOOKS_DIR/$hook_name.sh"
88
+
89
+ if [ ! -f "$hook_script" ]; then
90
+ printf '[SKIP] %s — hook script not found at %s\n' "$hook_name" "$hook_script"
91
+ SKIPPED=$((SKIPPED + 1))
92
+ continue
93
+ fi
94
+
95
+ if [ ! -x "$hook_script" ]; then
96
+ printf '[SKIP] %s — hook script not executable (run: chmod +x %s)\n' "$hook_name" "$hook_script"
97
+ SKIPPED=$((SKIPPED + 1))
98
+ continue
99
+ fi
100
+
101
+ printf '\n%s\n' "$hook_name"
102
+
103
+ count=$(jq -r 'length' "$case_file")
104
+ for i in $(seq 0 $((count - 1))); do
105
+ case_json=$(jq -c ".[$i]" "$case_file")
106
+ run_case "$hook_script" "$case_json"
107
+ done
108
+ done
109
+
110
+ printf '\n---\n%d passed, %d failed, %d skipped\n' "$PASSED" "$FAILED" "$SKIPPED"
111
+
112
+ if [ "$FAILED" -gt 0 ]; then
113
+ printf '\nFailed cases:\n'
114
+ for f in "${FAILURES[@]}"; do
115
+ printf ' - %s\n' "$f"
116
+ done
117
+ exit 1
118
+ fi
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # Forge safety guardrail — blocks writes to build artifacts, binaries, and
3
+ # dependency directories. PreToolUse hook for Edit|Write. Exit 2 = block.
4
+
5
+ set -uo pipefail
6
+
7
+ if ! command -v jq >/dev/null 2>&1; then
8
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"jq is required for file protection hooks but is not installed.\"}}"
9
+ exit 2
10
+ fi
11
+
12
+ INPUT=$(cat)
13
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
14
+ [ -z "$FILE_PATH" ] && exit 0
15
+
16
+ # Dependency, build, and cache directories across common stacks.
17
+ REASON=""
18
+ case "$FILE_PATH" in
19
+ # Node / JS / TS
20
+ node_modules/*|*/node_modules/*)
21
+ REASON="Cannot write into node_modules/ — install dependencies via the package manager instead." ;;
22
+ dist/*|*/dist/*|build/*|*/build/*|.next/*|*/.next/*|.nuxt/*|*/.nuxt/*|.output/*|*/.output/*)
23
+ REASON="Cannot write into build output directories — these are generated by the build process." ;;
24
+ # PHP / Laravel
25
+ vendor/*|*/vendor/*)
26
+ REASON="Cannot write into vendor/ — use composer instead." ;;
27
+ storage/framework/*|*/storage/framework/*|bootstrap/cache/*|*/bootstrap/cache/*)
28
+ REASON="Cannot write into Laravel framework caches — these are rebuilt by artisan." ;;
29
+ storage/logs/*|*/storage/logs/*)
30
+ REASON="Cannot write into storage/logs/ — these are runtime logs." ;;
31
+ # Python
32
+ __pycache__/*|*/__pycache__/*)
33
+ REASON="Cannot write into __pycache__/ — these are generated by Python." ;;
34
+ .venv/*|*/.venv/*|venv/*|*/venv/*)
35
+ REASON="Cannot write into virtual environment directories." ;;
36
+ .pytest_cache/*|*/.pytest_cache/*|.ruff_cache/*|*/.ruff_cache/*|.mypy_cache/*|*/.mypy_cache/*|.tox/*|*/.tox/*)
37
+ REASON="Cannot write into Python tool caches (pytest/ruff/mypy/tox) — these are regenerated automatically." ;;
38
+ # Go / Rust
39
+ target/debug/*|*/target/debug/*|target/release/*|*/target/release/*)
40
+ REASON="Cannot write into Rust target/ build output." ;;
41
+ # Generic
42
+ coverage/*|*/coverage/*|htmlcov/*|*/htmlcov/*|.nyc_output/*|*/.nyc_output/*)
43
+ REASON="Cannot write into coverage reports — these are regenerated by the test runner." ;;
44
+ .DS_Store|*/.DS_Store)
45
+ REASON="Cannot write .DS_Store — macOS metadata files should not be committed." ;;
46
+ esac
47
+
48
+ if [ -n "$REASON" ]; then
49
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
50
+ exit 2
51
+ fi
52
+
53
+ # Block binary and archive file extensions.
54
+ BASENAME=$(basename "$FILE_PATH")
55
+ case "$BASENAME" in
56
+ *.wasm|*.so|*.dylib|*.dll|*.exe|*.o|*.a)
57
+ REASON="Cannot write binary files — these should be compiled, not hand-written." ;;
58
+ *.zip|*.tar|*.tar.gz|*.tar.bz2|*.tgz|*.rar|*.7z|*.phar)
59
+ REASON="Cannot write archive/bundle files." ;;
60
+ *.mp4|*.mov|*.avi|*.mkv|*.mp3|*.wav|*.flac)
61
+ REASON="Cannot write media files — add these manually outside the agent." ;;
62
+ *.pyc|*.pyo|*.class)
63
+ REASON="Cannot write compiled bytecode files." ;;
64
+ esac
65
+
66
+ if [ -n "$REASON" ]; then
67
+ echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
68
+ exit 2
69
+ fi
70
+
71
+ exit 0