forge-orkes 0.42.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 +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/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 +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 +13 -1
- package/template/.claude/skills/reviewing/SKILL.md +2 -0
- package/template/.claude/skills/verifying/SKILL.md +19 -8
- package/template/.forge/FORGE.md +16 -3
- package/template/.forge/migrations/0.43.0-safety-policy.md +60 -0
- package/template/.forge/migrations/0.44.0-desire-paths.md +57 -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,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
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Rules
|
|
2
|
+
|
|
3
|
+
Rules are short, ambient reminders that load passively based on which files are being edited. They are the lightweight counterpart to skills — cheap, file-path-triggered convention reminders that cost no context until you touch the relevant code. See [ADR-018](../../docs/decisions/ADR-018-lightweight-policy-layer.md).
|
|
4
|
+
|
|
5
|
+
## The three weight tiers
|
|
6
|
+
|
|
7
|
+
Forge governs agent behaviour at three different weights. Rules fill the gap between the always-loaded heavy constitution and the opt-in heavy skills.
|
|
8
|
+
|
|
9
|
+
| Tier | Where | Weight | Loads when |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| **Constitution** | `.forge/constitution.md` | Heavy, project-wide hard rules | Always loaded — architectural **gates** that must pass |
|
|
12
|
+
| **Rules** | `.claude/rules/` | Lightweight ambient guidance | Always-on (no frontmatter) **or** path-scoped (auto-attaches only when editing matching files) |
|
|
13
|
+
| **Skills** | `.claude/skills/` | Heavy, full checklists/decision trees | Opt-in — loaded when a task description matches the skill |
|
|
14
|
+
|
|
15
|
+
- **Constitution** = the non-negotiable architectural gates that gate shipping. Heavy, always paid, project-specific.
|
|
16
|
+
- **Rules** = "don't add a phantom dependency", "re-read conventions on long sessions" — one-line bullets that fire ambiently. Path-scoped rules cost **zero context** until you touch a file matching their glob.
|
|
17
|
+
- **Skills** = "how we run a security review" (50–200 lines, invoked deliberately by task description).
|
|
18
|
+
|
|
19
|
+
The gap rules fill: the constitution is too heavy and too project-architectural for everyday convention reminders, and skills are opt-in so they miss ambient failure modes. Rules are the cheap, file-path-triggered middle layer.
|
|
20
|
+
|
|
21
|
+
Think of it this way:
|
|
22
|
+
|
|
23
|
+
- Constitution = "every API route enforces tenant isolation" (a gate, always checked)
|
|
24
|
+
- Skill = "how we run a security review" (50–200 lines, invoked deliberately)
|
|
25
|
+
- Rule = "don't call an API before grepping that it exists" (1-line bullet, fires ambiently)
|
|
26
|
+
|
|
27
|
+
If a rule grows past 25 lines, it is a skill in disguise. Split it or promote it.
|
|
28
|
+
|
|
29
|
+
## File format
|
|
30
|
+
|
|
31
|
+
Every rule is a markdown file. Two modes:
|
|
32
|
+
|
|
33
|
+
**Always-on:** no frontmatter. Claude Code loads rules without a `paths:` field unconditionally (same priority as `.claude/CLAUDE.md`). Use for universal discipline (agent behaviour).
|
|
34
|
+
|
|
35
|
+
**Path-scoped:** YAML frontmatter with a `paths:` array of globs.
|
|
36
|
+
```yaml
|
|
37
|
+
---
|
|
38
|
+
paths:
|
|
39
|
+
- "src/**/*.ts"
|
|
40
|
+
- "app/**/Http/**"
|
|
41
|
+
---
|
|
42
|
+
```
|
|
43
|
+
Loaded only when Claude is reading or editing a file whose path matches any glob. Use for stack-specific or layer-specific guidance.
|
|
44
|
+
|
|
45
|
+
Do not use `alwaysApply: true`. That is Cursor syntax; Claude Code ignores it. Absence of `paths:` is the always-on signal.
|
|
46
|
+
|
|
47
|
+
## Stack-specific rules ship opt-in
|
|
48
|
+
|
|
49
|
+
Forge core is **stack-agnostic**, so core ships only the always-on `agent-discipline.md`. Stack-specific path-scoped rules (Laravel, Vue, Python, etc.) live in the opt-in convention packs under `experimental/conventions/` — install them when your project's stack matches, and they auto-attach via their `paths:` globs. Keeping them out of core is what keeps core portable across any stack.
|
|
50
|
+
|
|
51
|
+
## Current rules
|
|
52
|
+
|
|
53
|
+
| File | Scope | Topic |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `agent-discipline.md` | always | Scope creep, phantom deps, hallucinated APIs, silent assumptions |
|
|
56
|
+
|
|
57
|
+
## Authoring rules
|
|
58
|
+
|
|
59
|
+
- Keep each rule under ~20 lines of actual guidance.
|
|
60
|
+
- Lead with the imperative ("Never use ...", "Always declare ..."), not justification.
|
|
61
|
+
- No code blocks unless the exact syntax is the point.
|
|
62
|
+
- One file per concern. Do not bundle unrelated rules into a single file just because they share a path.
|
|
63
|
+
- If a rule needs a long explanation, it belongs in a skill. Rules state the rule; skills teach it. Past 25 lines, it's a skill in disguise.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Agent Discipline
|
|
2
|
+
|
|
3
|
+
Always-on rule. These are the failure modes AI agents fall into most often. They apply to every session, regardless of stack. This restates the everyday floor; the executor's Deviation Rules (Rule 4 — STOP and ask before a new DB table, service layer, or library swap) in `.claude/agents/executor.md` are the hard stops.
|
|
4
|
+
|
|
5
|
+
- **Scope creep.** Do not modify files outside the stated scope. If a change requires touching something adjacent, ask first.
|
|
6
|
+
- **No phantom dependencies.** Do not add packages (`npm install`, `pip install`, `composer require`, `uv add`, etc.) without explicit approval. Name the package and version you need and wait. (A library swap is a Rule 4 stop.)
|
|
7
|
+
- **No over-engineering.** No abstractions, patterns, factories, registries, or base classes unless the plan calls for them. One concrete implementation does not need an interface.
|
|
8
|
+
- **No happy-path-only code.** Handle the unhappy paths the plan named. If the plan didn't name them, list what could go wrong before coding.
|
|
9
|
+
- **No hallucinated APIs.** Before calling a function or method, verify it exists with grep or by reading the source. If unsure, say so.
|
|
10
|
+
- **No silent assumptions.** Surface every non-obvious decision: "I assumed X because Y — confirm or redirect."
|
|
11
|
+
- **No "while I was here" extras.** No added logging, metrics, renames, or cleanup unless the task requires them.
|
|
12
|
+
- **Ask when unclear.** One good clarifying question beats an hour of wrong work.
|
|
13
|
+
- **Re-read conventions when the session gets long.** Style drifts over multi-hour sessions. The constitution and the relevant skill are the source of truth.
|
|
14
|
+
- **Test what you wrote.** Don't declare done without running the specific test or equivalent verification for the changed behaviour.
|