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.
- package/bin/create-forge.js +138 -1
- package/package.json +1 -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/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/forge-reserve.sh +162 -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/forge-reserve.test.sh +121 -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 +1 -1
- package/template/.claude/skills/chief-of-staff/SKILL.md +14 -0
- package/template/.claude/skills/executing/SKILL.md +16 -0
- package/template/.claude/skills/forge/SKILL.md +12 -1
- package/template/.claude/skills/initializing/SKILL.md +4 -0
- package/template/.claude/skills/planning/SKILL.md +14 -2
- package/template/.claude/skills/reviewing/SKILL.md +2 -0
- package/template/.claude/skills/verifying/SKILL.md +19 -8
- package/template/.forge/FORGE.md +32 -15
- 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/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
|