claude-crap 0.1.2

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 (202) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/LICENSE +21 -0
  3. package/README.md +550 -0
  4. package/bin/claude-crap.mjs +141 -0
  5. package/dist/adapters/bandit.d.ts +48 -0
  6. package/dist/adapters/bandit.d.ts.map +1 -0
  7. package/dist/adapters/bandit.js +145 -0
  8. package/dist/adapters/bandit.js.map +1 -0
  9. package/dist/adapters/common.d.ts +73 -0
  10. package/dist/adapters/common.d.ts.map +1 -0
  11. package/dist/adapters/common.js +78 -0
  12. package/dist/adapters/common.js.map +1 -0
  13. package/dist/adapters/eslint.d.ts +52 -0
  14. package/dist/adapters/eslint.d.ts.map +1 -0
  15. package/dist/adapters/eslint.js +142 -0
  16. package/dist/adapters/eslint.js.map +1 -0
  17. package/dist/adapters/index.d.ts +47 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +64 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/semgrep.d.ts +30 -0
  22. package/dist/adapters/semgrep.d.ts.map +1 -0
  23. package/dist/adapters/semgrep.js +130 -0
  24. package/dist/adapters/semgrep.js.map +1 -0
  25. package/dist/adapters/stryker.d.ts +55 -0
  26. package/dist/adapters/stryker.d.ts.map +1 -0
  27. package/dist/adapters/stryker.js +165 -0
  28. package/dist/adapters/stryker.js.map +1 -0
  29. package/dist/ast/cyclomatic.d.ts +48 -0
  30. package/dist/ast/cyclomatic.d.ts.map +1 -0
  31. package/dist/ast/cyclomatic.js +106 -0
  32. package/dist/ast/cyclomatic.js.map +1 -0
  33. package/dist/ast/index.d.ts +26 -0
  34. package/dist/ast/index.d.ts.map +1 -0
  35. package/dist/ast/index.js +23 -0
  36. package/dist/ast/index.js.map +1 -0
  37. package/dist/ast/language-config.d.ts +70 -0
  38. package/dist/ast/language-config.d.ts.map +1 -0
  39. package/dist/ast/language-config.js +192 -0
  40. package/dist/ast/language-config.js.map +1 -0
  41. package/dist/ast/tree-sitter-engine.d.ts +133 -0
  42. package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
  43. package/dist/ast/tree-sitter-engine.js +270 -0
  44. package/dist/ast/tree-sitter-engine.js.map +1 -0
  45. package/dist/config.d.ts +57 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +78 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/crap-config.d.ts +97 -0
  50. package/dist/crap-config.d.ts.map +1 -0
  51. package/dist/crap-config.js +144 -0
  52. package/dist/crap-config.js.map +1 -0
  53. package/dist/dashboard/server.d.ts +65 -0
  54. package/dist/dashboard/server.d.ts.map +1 -0
  55. package/dist/dashboard/server.js +147 -0
  56. package/dist/dashboard/server.js.map +1 -0
  57. package/dist/index.d.ts +32 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +574 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/metrics/crap.d.ts +71 -0
  62. package/dist/metrics/crap.d.ts.map +1 -0
  63. package/dist/metrics/crap.js +67 -0
  64. package/dist/metrics/crap.js.map +1 -0
  65. package/dist/metrics/index.d.ts +31 -0
  66. package/dist/metrics/index.d.ts.map +1 -0
  67. package/dist/metrics/index.js +27 -0
  68. package/dist/metrics/index.js.map +1 -0
  69. package/dist/metrics/score.d.ts +143 -0
  70. package/dist/metrics/score.d.ts.map +1 -0
  71. package/dist/metrics/score.js +224 -0
  72. package/dist/metrics/score.js.map +1 -0
  73. package/dist/metrics/tdr.d.ts +106 -0
  74. package/dist/metrics/tdr.d.ts.map +1 -0
  75. package/dist/metrics/tdr.js +117 -0
  76. package/dist/metrics/tdr.js.map +1 -0
  77. package/dist/metrics/workspace-walker.d.ts +43 -0
  78. package/dist/metrics/workspace-walker.d.ts.map +1 -0
  79. package/dist/metrics/workspace-walker.js +137 -0
  80. package/dist/metrics/workspace-walker.js.map +1 -0
  81. package/dist/sarif/index.d.ts +21 -0
  82. package/dist/sarif/index.d.ts.map +1 -0
  83. package/dist/sarif/index.js +19 -0
  84. package/dist/sarif/index.js.map +1 -0
  85. package/dist/sarif/sarif-builder.d.ts +128 -0
  86. package/dist/sarif/sarif-builder.d.ts.map +1 -0
  87. package/dist/sarif/sarif-builder.js +79 -0
  88. package/dist/sarif/sarif-builder.js.map +1 -0
  89. package/dist/sarif/sarif-store.d.ts +205 -0
  90. package/dist/sarif/sarif-store.d.ts.map +1 -0
  91. package/dist/sarif/sarif-store.js +246 -0
  92. package/dist/sarif/sarif-store.js.map +1 -0
  93. package/dist/sarif/sarif-validator.d.ts +45 -0
  94. package/dist/sarif/sarif-validator.d.ts.map +1 -0
  95. package/dist/sarif/sarif-validator.js +138 -0
  96. package/dist/sarif/sarif-validator.js.map +1 -0
  97. package/dist/schemas/tool-schemas.d.ts +216 -0
  98. package/dist/schemas/tool-schemas.d.ts.map +1 -0
  99. package/dist/schemas/tool-schemas.js +208 -0
  100. package/dist/schemas/tool-schemas.js.map +1 -0
  101. package/dist/sdk.d.ts +45 -0
  102. package/dist/sdk.d.ts.map +1 -0
  103. package/dist/sdk.js +44 -0
  104. package/dist/sdk.js.map +1 -0
  105. package/dist/tools/index.d.ts +24 -0
  106. package/dist/tools/index.d.ts.map +1 -0
  107. package/dist/tools/index.js +23 -0
  108. package/dist/tools/index.js.map +1 -0
  109. package/dist/tools/test-harness.d.ts +75 -0
  110. package/dist/tools/test-harness.d.ts.map +1 -0
  111. package/dist/tools/test-harness.js +137 -0
  112. package/dist/tools/test-harness.js.map +1 -0
  113. package/dist/workspace-guard.d.ts +53 -0
  114. package/dist/workspace-guard.d.ts.map +1 -0
  115. package/dist/workspace-guard.js +61 -0
  116. package/dist/workspace-guard.js.map +1 -0
  117. package/package.json +133 -0
  118. package/plugin/.claude-plugin/plugin.json +29 -0
  119. package/plugin/.mcp.json +18 -0
  120. package/plugin/CLAUDE.md +143 -0
  121. package/plugin/bundle/dashboard/public/index.html +368 -0
  122. package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
  123. package/plugin/bundle/mcp-server.mjs +8718 -0
  124. package/plugin/bundle/mcp-server.mjs.map +7 -0
  125. package/plugin/bundle/tdr-engine.mjs +50 -0
  126. package/plugin/bundle/tdr-engine.mjs.map +7 -0
  127. package/plugin/hooks/hooks.json +62 -0
  128. package/plugin/hooks/lib/crap-config.mjs +152 -0
  129. package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
  130. package/plugin/hooks/lib/hook-io.mjs +151 -0
  131. package/plugin/hooks/lib/quality-gate.mjs +329 -0
  132. package/plugin/hooks/lib/test-harness.mjs +152 -0
  133. package/plugin/hooks/post-tool-use.mjs +245 -0
  134. package/plugin/hooks/pre-tool-use.mjs +290 -0
  135. package/plugin/hooks/session-start.mjs +109 -0
  136. package/plugin/hooks/stop-quality-gate.mjs +226 -0
  137. package/plugin/package.json +18 -0
  138. package/plugin/skills/adopt/SKILL.md +74 -0
  139. package/plugin/skills/analyze/SKILL.md +77 -0
  140. package/plugin/skills/check-test/SKILL.md +50 -0
  141. package/plugin/skills/score/SKILL.md +31 -0
  142. package/scripts/bug-report.mjs +328 -0
  143. package/scripts/build-fast.mjs +130 -0
  144. package/scripts/bundle-plugin.mjs +74 -0
  145. package/scripts/doctor.mjs +320 -0
  146. package/scripts/install.mjs +192 -0
  147. package/scripts/lib/cli-ui.mjs +122 -0
  148. package/scripts/postinstall.mjs +127 -0
  149. package/scripts/run-tests.mjs +95 -0
  150. package/scripts/status.mjs +110 -0
  151. package/scripts/uninstall.mjs +72 -0
  152. package/src/adapters/bandit.ts +191 -0
  153. package/src/adapters/common.ts +133 -0
  154. package/src/adapters/eslint.ts +187 -0
  155. package/src/adapters/index.ts +78 -0
  156. package/src/adapters/semgrep.ts +150 -0
  157. package/src/adapters/stryker.ts +218 -0
  158. package/src/ast/cyclomatic.ts +131 -0
  159. package/src/ast/index.ts +33 -0
  160. package/src/ast/language-config.ts +231 -0
  161. package/src/ast/tree-sitter-engine.ts +385 -0
  162. package/src/config.ts +109 -0
  163. package/src/crap-config.ts +196 -0
  164. package/src/dashboard/public/index.html +368 -0
  165. package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
  166. package/src/dashboard/server.ts +205 -0
  167. package/src/index.ts +696 -0
  168. package/src/metrics/crap.ts +101 -0
  169. package/src/metrics/index.ts +51 -0
  170. package/src/metrics/score.ts +329 -0
  171. package/src/metrics/tdr.ts +155 -0
  172. package/src/metrics/workspace-walker.ts +146 -0
  173. package/src/sarif/index.ts +31 -0
  174. package/src/sarif/sarif-builder.ts +139 -0
  175. package/src/sarif/sarif-store.ts +347 -0
  176. package/src/sarif/sarif-validator.ts +145 -0
  177. package/src/schemas/tool-schemas.ts +225 -0
  178. package/src/sdk.ts +110 -0
  179. package/src/tests/adapters/bandit.test.ts +111 -0
  180. package/src/tests/adapters/dispatch.test.ts +100 -0
  181. package/src/tests/adapters/eslint.test.ts +138 -0
  182. package/src/tests/adapters/semgrep.test.ts +125 -0
  183. package/src/tests/adapters/stryker.test.ts +103 -0
  184. package/src/tests/crap-config.test.ts +228 -0
  185. package/src/tests/crap.test.ts +59 -0
  186. package/src/tests/cyclomatic.test.ts +87 -0
  187. package/src/tests/dashboard-http.test.ts +108 -0
  188. package/src/tests/dashboard-integrity.test.ts +128 -0
  189. package/src/tests/integration/mcp-server.integration.test.ts +352 -0
  190. package/src/tests/pre-tool-use-hook.test.ts +178 -0
  191. package/src/tests/sarif-store.test.ts +241 -0
  192. package/src/tests/sarif-validator.test.ts +164 -0
  193. package/src/tests/score.test.ts +260 -0
  194. package/src/tests/skills-frontmatter.test.ts +172 -0
  195. package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
  196. package/src/tests/tdr.test.ts +86 -0
  197. package/src/tests/test-harness.test.ts +153 -0
  198. package/src/tests/workspace-guard.test.ts +111 -0
  199. package/src/tools/index.ts +24 -0
  200. package/src/tools/test-harness.ts +158 -0
  201. package/src/workspace-guard.ts +64 -0
  202. package/tsconfig.json +27 -0
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * claude-crap :: PostToolUse hook — retrospective verifier.
5
+ *
6
+ * This hook runs immediately AFTER a Write / Edit / MultiEdit / NotebookEdit
7
+ * call has mutated a file on disk. Its job is to inspect the artifact the
8
+ * agent just produced and surface anything that the PreToolUse gatekeeper
9
+ * could not catch without the finished file:
10
+ *
11
+ * - Missing test harness for a production source file
12
+ * (Golden Rule enforcement from CLAUDE.md).
13
+ * - Crude "silenced warning" signatures (`eslint-disable`, `// @ts-ignore`,
14
+ * `# nosec`, `# type: ignore`). These often hide real issues.
15
+ * - TODO / FIXME / XXX markers in newly committed code.
16
+ *
17
+ * PostToolUse is **non-blocking** by design: every finding is emitted on
18
+ * stderr as a warning and the tool call is allowed to proceed. The agent
19
+ * is expected to read the warnings and remediate on its next turn, and
20
+ * the Stop quality gate will block the task close if any violation
21
+ * persists all the way to the end.
22
+ *
23
+ * The hook is intentionally cheap: only the artifact's path and raw
24
+ * bytes are examined. Deep SAST / CRAP / TDR analysis is deferred to the
25
+ * Stop hook which calls the MCP server.
26
+ *
27
+ * @module hooks/post-tool-use
28
+ */
29
+
30
+ import { promises as fs } from "node:fs";
31
+ import { resolve } from "node:path";
32
+
33
+ import { ExitCodes, readStdinJson, runHook, warnNonBlocking } from "./lib/hook-io.mjs";
34
+ import { findTestFile, isTestFile } from "./lib/test-harness.mjs";
35
+
36
+ const WORKSPACE_ROOT = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
37
+
38
+ /**
39
+ * Source extensions we care about for the test-harness rule. Files
40
+ * outside this list (README, YAML, JSON, etc.) are skipped silently.
41
+ */
42
+ const PRODUCTION_SOURCE_EXTENSIONS = new Set([
43
+ ".ts",
44
+ ".tsx",
45
+ ".mts",
46
+ ".cts",
47
+ ".js",
48
+ ".jsx",
49
+ ".mjs",
50
+ ".cjs",
51
+ ".py",
52
+ ".java",
53
+ ".cs",
54
+ ]);
55
+
56
+ /**
57
+ * Inline suppression patterns. Each entry is a regex we look for in the
58
+ * just-written file. When matched, the hook emits a warning naming the
59
+ * suppression and asking the agent to remove it.
60
+ */
61
+ const SUPPRESSION_PATTERNS = [
62
+ { id: "SUPP-ESLINT-DISABLE", re: /\beslint-disable(?:-next-line)?\b/, tool: "ESLint" },
63
+ { id: "SUPP-TS-IGNORE", re: /@ts-ignore/, tool: "TypeScript" },
64
+ { id: "SUPP-TS-EXPECT-ERROR", re: /@ts-expect-error/, tool: "TypeScript" },
65
+ { id: "SUPP-NOSEC", re: /#\s*nosec/, tool: "Bandit" },
66
+ { id: "SUPP-TYPE-IGNORE", re: /#\s*type:\s*ignore/, tool: "mypy / pyright" },
67
+ ];
68
+
69
+ const TODO_MARKER_REGEX = /\b(TODO|FIXME|XXX|HACK)\b/;
70
+
71
+ /**
72
+ * @typedef {Object} HookInput
73
+ * @property {string} [session_id]
74
+ * @property {string} [hook_event_name]
75
+ * @property {string} tool_name
76
+ * @property {Record<string, unknown>} tool_input
77
+ * @property {Record<string, unknown>} [tool_response]
78
+ */
79
+
80
+ /**
81
+ * Validate the minimum structural shape of a PostToolUse payload. Throws
82
+ * with a descriptive error when the payload is unrecognizable — the
83
+ * caller's fail-open harness will degrade gracefully.
84
+ *
85
+ * @param {unknown} payload
86
+ * @returns {HookInput}
87
+ */
88
+ function validate(payload) {
89
+ if (!payload || typeof payload !== "object") throw new Error("payload is not an object");
90
+ const p = /** @type {Record<string, unknown>} */ (payload);
91
+ if (typeof p.tool_name !== "string") throw new Error("payload.tool_name missing");
92
+ if (!p.tool_input || typeof p.tool_input !== "object") throw new Error("payload.tool_input missing");
93
+ return /** @type {HookInput} */ (p);
94
+ }
95
+
96
+ /**
97
+ * Extract the target file path from the tool input, if any. Returns
98
+ * `null` when the tool does not operate on a single file (e.g. Bash).
99
+ *
100
+ * @param {HookInput} input
101
+ * @returns {string | null}
102
+ */
103
+ function extractTargetFile(input) {
104
+ const fp = input.tool_input.file_path;
105
+ if (typeof fp === "string") return fp;
106
+ const np = input.tool_input.notebook_path;
107
+ if (typeof np === "string") return np;
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Is this extension one of the production source languages we guard?
113
+ *
114
+ * @param {string} filePath
115
+ * @returns {boolean}
116
+ */
117
+ function isProductionSource(filePath) {
118
+ for (const ext of PRODUCTION_SOURCE_EXTENSIONS) {
119
+ if (filePath.endsWith(ext)) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Rule: production source files must have an accompanying test file.
126
+ *
127
+ * Fires a `SONAR-TEST-MISSING` warning when none of the conventional
128
+ * test locations exists. Does not block — the Stop gate enforces the
129
+ * strict verdict.
130
+ *
131
+ * @param {string} filePath Absolute path to the artifact just written.
132
+ * @param {string} toolName Name of the tool that wrote it (for logging).
133
+ */
134
+ async function checkTestHarness(filePath, toolName) {
135
+ if (isTestFile(filePath)) return;
136
+ if (!isProductionSource(filePath)) return;
137
+
138
+ const resolution = await findTestFile(WORKSPACE_ROOT, filePath);
139
+ if (resolution.testFile) return;
140
+
141
+ warnNonBlocking({
142
+ title: "PostToolUse",
143
+ ruleId: "SONAR-TEST-MISSING",
144
+ tool: toolName,
145
+ reason:
146
+ `No test file was found for '${filePath}'. ` +
147
+ `The Golden Rule in CLAUDE.md requires a test harness to accompany every production source file. ` +
148
+ `Corrective action: before the Stop quality gate runs, create a test next to the file or under the ` +
149
+ `mirror tree at 'tests/' with a name such as '${resolution.candidates[0]}'.`,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Rule: inline suppression markers are forbidden.
155
+ *
156
+ * Reads the just-written file and scans for common linter / type-checker
157
+ * suppression annotations. When found, emits a warning naming the tool
158
+ * whose output was being silenced.
159
+ *
160
+ * @param {string} filePath
161
+ * @param {string} toolName
162
+ */
163
+ async function checkSuppressionMarkers(filePath, toolName) {
164
+ let content;
165
+ try {
166
+ content = await fs.readFile(filePath, "utf8");
167
+ } catch {
168
+ // File not readable (unusual: PostToolUse runs after a successful write).
169
+ return;
170
+ }
171
+
172
+ for (const suppression of SUPPRESSION_PATTERNS) {
173
+ if (!suppression.re.test(content)) continue;
174
+ warnNonBlocking({
175
+ title: "PostToolUse",
176
+ ruleId: `SONAR-${suppression.id}`,
177
+ tool: toolName,
178
+ reason:
179
+ `Found a suppression marker (${suppression.id}) that silences ${suppression.tool}. ` +
180
+ `CLAUDE.md forbids silencing findings — fix the underlying issue instead. ` +
181
+ `Corrective action: remove the suppression and address the warning it was hiding. If the warning ` +
182
+ `is truly a false positive, add an entry to the tool's configuration file with a clear rationale.`,
183
+ });
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Rule: freshly committed TODO/FIXME markers are tracked.
189
+ *
190
+ * Not every TODO is a defect, but TODOs that slip into committed code
191
+ * are a leading indicator of technical debt. We emit a single aggregated
192
+ * warning per file rather than one per line.
193
+ *
194
+ * @param {string} filePath
195
+ * @param {string} toolName
196
+ */
197
+ async function checkTodoMarkers(filePath, toolName) {
198
+ let content;
199
+ try {
200
+ content = await fs.readFile(filePath, "utf8");
201
+ } catch {
202
+ return;
203
+ }
204
+ const lines = content.split(/\r?\n/);
205
+ /** @type {number[]} */
206
+ const hits = [];
207
+ lines.forEach((line, idx) => {
208
+ if (TODO_MARKER_REGEX.test(line)) hits.push(idx + 1);
209
+ });
210
+ if (hits.length === 0) return;
211
+
212
+ warnNonBlocking({
213
+ title: "PostToolUse",
214
+ ruleId: "SONAR-TODO-MARKER",
215
+ tool: toolName,
216
+ reason:
217
+ `Found ${hits.length} TODO/FIXME/HACK marker(s) in '${filePath}' at line(s) ${hits.slice(0, 5).join(", ")}` +
218
+ `${hits.length > 5 ? ", ..." : ""}. ` +
219
+ `These are tracked by the TDR engine as debt. Either resolve them now or open a linked ticket and ` +
220
+ `reference it in the comment so the Stop gate can audit the backlog.`,
221
+ });
222
+ }
223
+
224
+ async function main() {
225
+ const payload = await readStdinJson();
226
+ const input = validate(payload);
227
+ const filePath = extractTargetFile(input);
228
+ if (!filePath) {
229
+ // Tool did not write a file (e.g. Bash). Nothing to verify.
230
+ process.exit(ExitCodes.ALLOW);
231
+ }
232
+
233
+ const absolute = resolve(WORKSPACE_ROOT, filePath);
234
+ await checkTestHarness(absolute, input.tool_name);
235
+ await checkSuppressionMarkers(absolute, input.tool_name);
236
+ await checkTodoMarkers(absolute, input.tool_name);
237
+
238
+ // PostToolUse never blocks — always allow. Warnings already wrote to stderr.
239
+ process.stdout.write(
240
+ JSON.stringify({ status: "verified", tool: input.tool_name, file: filePath }) + "\n",
241
+ );
242
+ process.exit(ExitCodes.ALLOW);
243
+ }
244
+
245
+ runHook("PostToolUse", main);
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * claude-crap :: PreToolUse hook — prophylactic gatekeeper.
5
+ *
6
+ * Contract with Claude Code (see https://code.claude.com/docs/en/hooks):
7
+ *
8
+ * stdin : JSON payload `{ session_id, tool_name, tool_input, ... }`
9
+ * stdout : Free-form informational output. Never interpreted by Claude.
10
+ * stderr : Corrective message injected into the agent's context when
11
+ * the hook exits with code 2.
12
+ * exit 0 : Allow the tool call.
13
+ * exit 2 : ABORT the tool call. Claude Code forwards stderr to the LLM.
14
+ * exit N : Non-zero, non-2 exit. Treated by Claude Code as "allow"
15
+ * (fail-open). claude-crap uses this code only for LOW-RISK
16
+ * tools when the hook itself errors; HIGH-RISK tools always
17
+ * fall back to exit 2 (fail-closed) — see the allowlist below.
18
+ *
19
+ * Deterministic design principles:
20
+ *
21
+ * - Zero network I/O.
22
+ * - Zero filesystem I/O beyond reading stdin and writing stderr.
23
+ * - Target latency: under 200 ms in the common case.
24
+ * - All rules live in `./lib/gatekeeper-rules.mjs` so they can be tested
25
+ * in isolation without spinning up Claude Code.
26
+ *
27
+ * Fail-open vs fail-closed (F-A06-01):
28
+ *
29
+ * The CLAUDE.md contract states that "none of your proposals bypass
30
+ * those filters." A fully fail-open gatekeeper would break that
31
+ * contract the instant any rule throws an exception or any payload
32
+ * fails to parse. But a fully fail-closed gatekeeper would deadlock
33
+ * the user whenever Claude Code sends an unusual payload, which is
34
+ * also unacceptable.
35
+ *
36
+ * The compromise is an allowlist of HIGH-risk tool names: Write,
37
+ * Edit, MultiEdit, NotebookEdit, Bash. For those tools, ANY failure
38
+ * to evaluate the rules (parse error, rule throw, validator throw)
39
+ * exits 2 with a structured corrective message. For every other
40
+ * tool, the legacy fail-open behavior is preserved.
41
+ *
42
+ * When the stdin payload is unparseable we still try to recover the
43
+ * tool name via a best-effort regex so the fail-closed check can
44
+ * trigger even in the degraded path.
45
+ *
46
+ * What this hook does NOT do:
47
+ *
48
+ * Deep SAST, CRAP computation, tree-sitter AST parsing, coverage lookup
49
+ * and SARIF aggregation all live in PostToolUse (retrospective) or Stop
50
+ * (final quality gate). Those stages call the MCP server. The PreToolUse
51
+ * hook is intentionally a cheap synchronous speed bump.
52
+ *
53
+ * @module hooks/pre-tool-use
54
+ */
55
+
56
+ import { runAllRules } from "./lib/gatekeeper-rules.mjs";
57
+
58
+ /** Allow the tool call to proceed. */
59
+ const EXIT_ALLOW = 0;
60
+ /** Block the tool call and inject `stderr` into the agent's context. */
61
+ const EXIT_BLOCK = 2;
62
+ /** Internal hook failure (fail-open for LOW-risk tools only). */
63
+ const EXIT_INTERNAL_ERROR = 1;
64
+
65
+ /**
66
+ * Tool names for which the gatekeeper MUST fail closed when it cannot
67
+ * evaluate the payload. These are exactly the tools that can mutate the
68
+ * workspace or execute a shell, so bypassing the gatekeeper for them
69
+ * would violate the CLAUDE.md Golden Rule.
70
+ */
71
+ const HIGH_RISK_TOOLS = Object.freeze(
72
+ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"]),
73
+ );
74
+
75
+ /**
76
+ * Best-effort regex used to recover `tool_name` from stdin that does not
77
+ * parse as JSON. The regex is intentionally lenient: it just looks for
78
+ * the first `"tool_name": "<something>"` substring anywhere in the raw
79
+ * input. When nothing matches we return `null` and the caller treats
80
+ * the tool as unknown (which means fail-open).
81
+ */
82
+ const TOOL_NAME_EXTRACT_RE = /"tool_name"\s*:\s*"([^"]+)"/;
83
+
84
+ /**
85
+ * Read the full stdin stream as a raw UTF-8 string. Kept separate from
86
+ * JSON parsing so the caller can attempt a best-effort `tool_name`
87
+ * extraction on malformed input.
88
+ *
89
+ * @returns {Promise<string>} The stdin contents, trimmed of surrounding whitespace.
90
+ */
91
+ async function readStdinRaw() {
92
+ /** @type {Buffer[]} */
93
+ const chunks = [];
94
+ for await (const chunk of process.stdin) {
95
+ chunks.push(/** @type {Buffer} */ (chunk));
96
+ }
97
+ return Buffer.concat(chunks).toString("utf8").trim();
98
+ }
99
+
100
+ /**
101
+ * Parse a raw stdin string as JSON, rethrowing with a friendly message.
102
+ *
103
+ * @param {string} raw Raw stdin contents.
104
+ * @returns {object} The parsed JSON payload.
105
+ * @throws When the string is empty or not valid JSON.
106
+ */
107
+ function parseStdinJson(raw) {
108
+ if (!raw) {
109
+ throw new Error("stdin was empty — claude-crap PreToolUse expected a hook JSON payload");
110
+ }
111
+ try {
112
+ return JSON.parse(raw);
113
+ } catch (err) {
114
+ throw new Error(`stdin is not valid JSON: ${/** @type {Error} */ (err).message}`);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Validate that the payload has the minimum shape of a Claude Code hook
120
+ * and narrow its type for downstream consumers. Throws when the payload
121
+ * is unrecognizable — the caller handles the fail-closed / fail-open
122
+ * decision.
123
+ *
124
+ * @param {unknown} payload Raw parsed JSON from stdin.
125
+ * @returns {import("./lib/gatekeeper-rules.mjs").HookInput}
126
+ * @throws When required fields are missing or the wrong type.
127
+ */
128
+ function validateHookPayload(payload) {
129
+ if (!payload || typeof payload !== "object") {
130
+ throw new Error("payload is not an object");
131
+ }
132
+ const p = /** @type {Record<string, unknown>} */ (payload);
133
+ if (typeof p.tool_name !== "string") {
134
+ throw new Error("payload.tool_name is missing or not a string");
135
+ }
136
+ if (!p.tool_input || typeof p.tool_input !== "object") {
137
+ throw new Error("payload.tool_input is missing or not an object");
138
+ }
139
+ return /** @type {import("./lib/gatekeeper-rules.mjs").HookInput} */ (p);
140
+ }
141
+
142
+ /**
143
+ * Best-effort extractor for `tool_name` from unparseable stdin. Used as
144
+ * a last resort when the gatekeeper needs to decide between fail-open
145
+ * and fail-closed but the payload never made it through `JSON.parse`.
146
+ *
147
+ * @param {string} raw Raw stdin contents.
148
+ * @returns {string | null} The extracted tool name, or `null`.
149
+ */
150
+ function extractToolNameFromRaw(raw) {
151
+ if (!raw) return null;
152
+ const match = TOOL_NAME_EXTRACT_RE.exec(raw);
153
+ return match && typeof match[1] === "string" ? match[1] : null;
154
+ }
155
+
156
+ /**
157
+ * `true` when the given tool name is in the high-risk allowlist and
158
+ * therefore must fail closed on any internal hook error.
159
+ *
160
+ * @param {string | null | undefined} toolName
161
+ * @returns {boolean}
162
+ */
163
+ function isHighRiskTool(toolName) {
164
+ return typeof toolName === "string" && HIGH_RISK_TOOLS.has(toolName);
165
+ }
166
+
167
+ /**
168
+ * Render the fail-closed corrective message. Claude Code injects this
169
+ * text into the agent's context when the hook exits with code 2, so it
170
+ * must be imperative and actionable.
171
+ *
172
+ * @param {Object} opts
173
+ * @param {string} opts.toolName Recovered tool name (e.g. `"Write"`).
174
+ * @param {string} opts.phase Which phase failed — `"parse"` or `"evaluate"`.
175
+ * @param {string} opts.detail Short technical description of the failure.
176
+ * @returns {string} Multi-line box ready to write to stderr.
177
+ */
178
+ function renderFailClosedMessage({ toolName, phase, detail }) {
179
+ return [
180
+ "╭─ claude-crap :: PreToolUse BLOCKED (fail-closed) ───────────────",
181
+ "│ rule : SONAR-GATEKEEPER-FAILCLOSED",
182
+ `│ tool : ${toolName}`,
183
+ `│ phase: ${phase}`,
184
+ "│",
185
+ "│ The gatekeeper could not evaluate this call and the tool is in",
186
+ "│ the high-risk allowlist (Write, Edit, MultiEdit, NotebookEdit,",
187
+ "│ Bash). Per CLAUDE.md, enforcement must not be bypassed by a hook",
188
+ "│ failure for tools that mutate files or run a shell.",
189
+ "│",
190
+ "│ Corrective action: fix the gatekeeper bug surfaced by the detail",
191
+ "│ line below, then retry. If you cannot fix it, ask the user to",
192
+ "│ invoke a non-mutating tool (Read, Glob, Grep) to inspect the",
193
+ "│ situation instead.",
194
+ "│",
195
+ `│ detail: ${detail}`,
196
+ "╰──────────────────────────────────────────────────────────────────",
197
+ ].join("\n");
198
+ }
199
+
200
+ /**
201
+ * Handle a hook internal error uniformly. Decides between fail-closed
202
+ * (exit 2, for high-risk tools) and fail-open (exit 1, for everything
203
+ * else), writes the appropriate message to stderr, and exits. Never
204
+ * returns.
205
+ *
206
+ * @param {Object} opts
207
+ * @param {string | null} opts.toolName Best-effort recovered tool name.
208
+ * @param {string} opts.phase `"parse"` or `"evaluate"`.
209
+ * @param {string} opts.detail Short technical reason.
210
+ * @returns {never}
211
+ */
212
+ function exitOnInternalError({ toolName, phase, detail }) {
213
+ if (isHighRiskTool(toolName)) {
214
+ process.stderr.write(
215
+ renderFailClosedMessage({
216
+ toolName: /** @type {string} */ (toolName),
217
+ phase,
218
+ detail,
219
+ }) + "\n",
220
+ );
221
+ process.exit(EXIT_BLOCK);
222
+ }
223
+ process.stderr.write(
224
+ `[claude-crap] PreToolUse: ${phase} failure (${detail}). ` +
225
+ `Tool '${toolName ?? "<unknown>"}' is not in the high-risk allowlist; ` +
226
+ `falling back to permissive mode (fail-open).\n`,
227
+ );
228
+ process.exit(EXIT_INTERNAL_ERROR);
229
+ }
230
+
231
+ /**
232
+ * Entrypoint. Reads the hook payload, runs every rule, and exits with
233
+ * the appropriate code. Any unexpected failure is routed through
234
+ * `exitOnInternalError`, which fails closed for high-risk tools.
235
+ */
236
+ async function main() {
237
+ /** @type {string} */
238
+ let raw = "";
239
+ /** @type {import("./lib/gatekeeper-rules.mjs").HookInput | null} */
240
+ let input = null;
241
+
242
+ try {
243
+ raw = await readStdinRaw();
244
+ const parsed = parseStdinJson(raw);
245
+ input = validateHookPayload(parsed);
246
+ } catch (err) {
247
+ const recoveredTool = extractToolNameFromRaw(raw);
248
+ exitOnInternalError({
249
+ toolName: recoveredTool,
250
+ phase: "parse",
251
+ detail: /** @type {Error} */ (err).message,
252
+ });
253
+ return; // unreachable, but keeps the type checker happy
254
+ }
255
+
256
+ try {
257
+ const verdict = runAllRules(input);
258
+ if (verdict && verdict.blocked) {
259
+ // Structured message for the LLM. Claude Code injects stderr into
260
+ // the agent's context whenever a hook exits with code 2, so this
261
+ // text effectively becomes a prompt. Keep it imperative and actionable.
262
+ const message = [
263
+ "╭─ claude-crap :: PreToolUse BLOCKED ────────────────────────────",
264
+ `│ rule : ${verdict.ruleId}`,
265
+ `│ tool : ${input.tool_name}`,
266
+ "│",
267
+ `│ ${verdict.reason}`,
268
+ "╰──────────────────────────────────────────────────────────────────",
269
+ ].join("\n");
270
+ process.stderr.write(`${message}\n`);
271
+ process.exit(EXIT_BLOCK);
272
+ return;
273
+ }
274
+
275
+ // Silent pass-through. We still emit a single JSON line on stdout so
276
+ // that the hooks transcript can be audited after the fact.
277
+ process.stdout.write(
278
+ JSON.stringify({ status: "allow", tool: input.tool_name, rules_evaluated: 4 }) + "\n",
279
+ );
280
+ process.exit(EXIT_ALLOW);
281
+ } catch (err) {
282
+ exitOnInternalError({
283
+ toolName: input.tool_name,
284
+ phase: "evaluate",
285
+ detail: /** @type {Error} */ (err).message,
286
+ });
287
+ }
288
+ }
289
+
290
+ main();
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * claude-crap :: SessionStart hook — baseline context seeder.
5
+ *
6
+ * Runs once when Claude Code starts a new interactive session with this
7
+ * plugin active. Its purpose is to fix the agent's opening mental model
8
+ * without paying for it in tokens later: the hook writes a short,
9
+ * structured briefing on stdout so Claude Code can inject it into the
10
+ * session's system context.
11
+ *
12
+ * The briefing contains:
13
+ *
14
+ * - A reminder of the Golden Rule and the hook contract.
15
+ * - The current configured thresholds (CRAP, TDR rating, LOC cost).
16
+ * - Baseline metrics pulled from the last consolidated SARIF report.
17
+ *
18
+ * Exit semantics:
19
+ *
20
+ * - Exit 0 → briefing written to stdout, Claude Code appends it to
21
+ * the session context.
22
+ * - Any other exit → fail-open; the session starts with no briefing.
23
+ *
24
+ * The hook never blocks, never reads the filesystem beyond the SARIF
25
+ * report, and never calls the MCP server — it must complete in under
26
+ * 10 seconds per the `hooks.json` timeout budget.
27
+ *
28
+ * @module hooks/session-start
29
+ */
30
+
31
+ import { ExitCodes, runHook } from "./lib/hook-io.mjs";
32
+ import { evaluateQualityGate, loadQualityGateConfig } from "./lib/quality-gate.mjs";
33
+
34
+ /**
35
+ * Render the opening briefing as Markdown. The text lands in the
36
+ * agent's system context, so it must be compact and imperative.
37
+ *
38
+ * @param {import("./lib/quality-gate.mjs").QualityGateConfig} config
39
+ * @param {import("./lib/quality-gate.mjs").GateVerdict} verdict
40
+ * @returns {string}
41
+ */
42
+ function renderBriefing(config, verdict) {
43
+ const { summary } = verdict;
44
+ return [
45
+ "## claude-crap session briefing",
46
+ "",
47
+ "This session is running under the claude-crap plugin. You are bound by:",
48
+ "",
49
+ "- **Golden Rule** — do not write functional code until a characterization test",
50
+ " pins the current behavior.",
51
+ "- **Hook contract** — PreToolUse can abort with exit 2, PostToolUse emits",
52
+ " warnings, Stop / SubagentStop enforce the quality gate.",
53
+ "- **Deterministic engines** — anchor decisions in the claude-crap MCP tools",
54
+ " (`compute_crap`, `compute_tdr`, `analyze_file_ast`, `ingest_sarif`,",
55
+ " `require_test_harness`) rather than in speculative reasoning.",
56
+ "",
57
+ "### Current policy",
58
+ "",
59
+ `- CRAP threshold: **${config.crapThreshold}** (block on any function above it)`,
60
+ `- Maintainability ceiling: **${config.tdrMaxRating}** (worse ratings halt the Stop gate)`,
61
+ `- Cost per LOC: **${config.minutesPerLoc}** minutes (used as the TDR denominator)`,
62
+ "",
63
+ "### Baseline workspace metrics",
64
+ "",
65
+ `- Workspace LOC: **${summary.physicalLoc}**`,
66
+ `- Total findings: **${summary.totalFindings}** ` +
67
+ `(error: ${summary.errorFindings}, warning: ${summary.warningFindings}, note: ${summary.noteFindings})`,
68
+ `- Remediation debt: **${summary.remediationMinutes} min**`,
69
+ `- TDR: **${summary.tdrPercent}%** → rating **${summary.tdrRating}**`,
70
+ summary.toolsSeen.length > 0
71
+ ? `- Scanners already ingested: ${summary.toolsSeen.join(", ")}`
72
+ : "- Scanners already ingested: <none>",
73
+ "",
74
+ verdict.passed
75
+ ? "The baseline currently passes the quality gate. Keep it that way."
76
+ : `⚠️ Baseline would FAIL the Stop gate (${verdict.failures.length} policy violation(s)). ` +
77
+ "Your first priority should be remediating existing findings before introducing new code.",
78
+ ].join("\n");
79
+ }
80
+
81
+ async function main() {
82
+ const config = loadQualityGateConfig();
83
+ let verdict;
84
+ try {
85
+ verdict = await evaluateQualityGate(config);
86
+ } catch (err) {
87
+ // If we cannot produce a verdict (e.g. MCP server not built yet),
88
+ // fail open: write a stripped briefing so the session still starts.
89
+ process.stderr.write(
90
+ `[claude-crap] SessionStart: could not evaluate baseline: ${/** @type {Error} */ (err).message}\n`,
91
+ );
92
+ process.stdout.write(
93
+ [
94
+ "## claude-crap session briefing",
95
+ "",
96
+ "claude-crap is active but the baseline quality gate could not run.",
97
+ "Run `cd src/mcp-server && npm run build` to enable deterministic metrics.",
98
+ "The Golden Rule still applies: no functional code without a prior test.",
99
+ ].join("\n") + "\n",
100
+ );
101
+ process.exit(ExitCodes.ALLOW);
102
+ return;
103
+ }
104
+
105
+ process.stdout.write(renderBriefing(config, verdict) + "\n");
106
+ process.exit(ExitCodes.ALLOW);
107
+ }
108
+
109
+ runHook("SessionStart", main);