claude-dev-env 8.4.2 → 8.4.3
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/package.json
CHANGED
|
@@ -32,8 +32,15 @@ $scriptsRoot = Split-Path -Parent (Split-Path -Parent $entrypoint)
|
|
|
32
32
|
$exitCode = 1
|
|
33
33
|
Push-Location $scriptsRoot
|
|
34
34
|
try {
|
|
35
|
-
& bun
|
|
36
|
-
$
|
|
35
|
+
& bun -e 'import { ensureDependenciesInstalled } from "./bootstrap.ts"; ensureDependenciesInstalled();'
|
|
36
|
+
$bootstrapExitCode = $LASTEXITCODE
|
|
37
|
+
if ($bootstrapExitCode -ne 0) {
|
|
38
|
+
Write-Error "The pstack dependencies could not be installed"
|
|
39
|
+
$exitCode = $bootstrapExitCode
|
|
40
|
+
} else {
|
|
41
|
+
& bun $entrypoint @commandArguments
|
|
42
|
+
$exitCode = $LASTEXITCODE
|
|
43
|
+
}
|
|
37
44
|
}
|
|
38
45
|
finally {
|
|
39
46
|
Pop-Location
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { writeFileSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
import { auditHooks } from "./audit-hooks.mjs";
|
|
6
|
-
|
|
7
|
-
function parseArguments(argv) {
|
|
8
|
-
const options = {
|
|
9
|
-
catalogPath: null,
|
|
10
|
-
format: "text",
|
|
11
|
-
homeDirectory: null,
|
|
12
|
-
includeInstalled: false,
|
|
13
|
-
outputPath: null,
|
|
14
|
-
requireNonblocking: false,
|
|
15
|
-
repositoryRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."),
|
|
16
|
-
};
|
|
17
|
-
for (let offset = 0; offset < argv.length; offset += 1) {
|
|
18
|
-
const argument = argv[offset];
|
|
19
|
-
if (argument === "--installed") options.includeInstalled = true;
|
|
20
|
-
else if (argument === "--catalog") options.catalogPath = argv[++offset];
|
|
21
|
-
else if (argument === "--format") options.format = argv[++offset];
|
|
22
|
-
else if (argument === "--home") options.homeDirectory = argv[++offset];
|
|
23
|
-
else if (argument === "--output") options.outputPath = argv[++offset];
|
|
24
|
-
else if (argument === "--require-nonblocking") options.requireNonblocking = true;
|
|
25
|
-
else if (argument === "--repository-root") options.repositoryRoot = argv[++offset];
|
|
26
|
-
else throw new Error(`unknown argument ${argument}`);
|
|
27
|
-
}
|
|
28
|
-
return options;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function renderText(report) {
|
|
32
|
-
const summary = report.summary;
|
|
33
|
-
return [
|
|
34
|
-
`canonical direct: ${summary.directCount}`,
|
|
35
|
-
`dispatchers: ${summary.dispatcherCount}`,
|
|
36
|
-
`hosted entries: ${summary.effectiveCount}`,
|
|
37
|
-
`logical associations: ${summary.logicalAssociationCount}`,
|
|
38
|
-
`unclassified targets: ${summary.unclassifiedCount}`,
|
|
39
|
-
`installed Claude direct: ${summary.claudeDirectCount}`,
|
|
40
|
-
`installed Codex direct: ${summary.codexDirectCount}`,
|
|
41
|
-
`active native Git hooks: ${summary.gitHookCount}`,
|
|
42
|
-
`known native Git hooks: ${summary.knownGitHookCount}`,
|
|
43
|
-
`registration eligibility: ${report.registrationEligibility.status}`,
|
|
44
|
-
"runtime proof: separate verification required",
|
|
45
|
-
`findings: ${summary.findingCount}`,
|
|
46
|
-
].join("\n");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function main() {
|
|
50
|
-
const options = parseArguments(process.argv.slice(2));
|
|
51
|
-
const report = auditHooks(options);
|
|
52
|
-
const rendered =
|
|
53
|
-
options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : `${renderText(report)}\n`;
|
|
54
|
-
if (options.outputPath) writeFileSync(options.outputPath, rendered, "utf8");
|
|
55
|
-
else process.stdout.write(rendered);
|
|
56
|
-
process.exitCode = report.findings.some(({ severity }) => severity === "error") ? 1 : 0;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
main();
|
package/scripts/audit-hooks.mjs
DELETED
|
@@ -1,452 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
import { KNOWN_GIT_HOOK_NAMES } from "../bin/git_hooks_installer.mjs";
|
|
7
|
-
|
|
8
|
-
const dispatcherSpecs = Object.freeze([
|
|
9
|
-
["script:blocking/pre_tool_use_dispatcher.py", "hooks_constants/pre_tool_use_dispatcher_constants.py", "ALL_HOSTED_HOOK_ENTRIES"],
|
|
10
|
-
["script:blocking/bash_pre_tool_use_dispatcher.py", "hooks_constants/bash_pre_tool_use_dispatcher_constants.py", "ALL_BASH_HOSTED_HOOK_ENTRIES"],
|
|
11
|
-
["script:validation/post_tool_use_dispatcher.py", "hooks_constants/post_tool_use_dispatcher_constants.py", "ALL_POST_HOSTED_HOOK_ENTRIES"],
|
|
12
|
-
["script:blocking/bash_post_call_dispatcher.py", "hooks_constants/bash_post_call_dispatcher_constants.py", "ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES"],
|
|
13
|
-
]);
|
|
14
|
-
const lifecycleNames = new Set([
|
|
15
|
-
"delete",
|
|
16
|
-
"keep_boundary_check",
|
|
17
|
-
"keep_nonblocking_automation",
|
|
18
|
-
"move_to_ci",
|
|
19
|
-
"move_to_explicit_command",
|
|
20
|
-
"move_to_linter",
|
|
21
|
-
]);
|
|
22
|
-
const nonblockingLifecycle = "keep_nonblocking_automation";
|
|
23
|
-
const blockingGitEvents = new Set(["pre-commit", "pre-push"]);
|
|
24
|
-
|
|
25
|
-
function normalizeMatcher(rawMatcher) {
|
|
26
|
-
return typeof rawMatcher === "string" && rawMatcher
|
|
27
|
-
? rawMatcher.split("|").sort().join("|")
|
|
28
|
-
: "*";
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function targetFromCommand(command, scope, event, ordinal) {
|
|
32
|
-
const normalized = command.replaceAll("\\", "/");
|
|
33
|
-
const hookMarker = "/hooks/";
|
|
34
|
-
if (normalized.includes(hookMarker)) {
|
|
35
|
-
const relative = normalized.split(hookMarker).at(-1).split(/[\s"']/u, 1)[0];
|
|
36
|
-
if (relative.endsWith(".py")) return `script:${relative}`;
|
|
37
|
-
}
|
|
38
|
-
const importMatch = /\bfrom\s+([A-Za-z_][\w.]*)\s+import\s+main\b/u.exec(command);
|
|
39
|
-
if (importMatch) return `script:${importMatch[1].replaceAll(".", "/")}.py`;
|
|
40
|
-
const scriptMatches = [...normalized.matchAll(/(?:^|\s)["']?([^"'/\s]+\.(?:exe|js|mjs|ps1|py))\b/gu)];
|
|
41
|
-
if (scriptMatches.length) return `external:${scriptMatches.at(-1)[1]}`;
|
|
42
|
-
return `external:${scope}:${event}:${ordinal}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function readDirect(configPath, scope) {
|
|
46
|
-
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
47
|
-
const records = [];
|
|
48
|
-
for (const [event, groups] of Object.entries(parsed.hooks ?? {})) {
|
|
49
|
-
for (const group of Array.isArray(groups) ? groups : []) {
|
|
50
|
-
appendGroupRecords(records, scope, event, group);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return records;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function appendGroupRecords(records, scope, event, group) {
|
|
57
|
-
const matcher = normalizeMatcher(group.matcher);
|
|
58
|
-
for (const hook of Array.isArray(group.hooks) ? group.hooks : []) {
|
|
59
|
-
if (hook?.type !== "command" || typeof hook.command !== "string") continue;
|
|
60
|
-
const ordinal = records.length;
|
|
61
|
-
const target = targetFromCommand(hook.command, scope, event, ordinal);
|
|
62
|
-
const isDispatcher = dispatcherSpecs.some(([knownTarget]) => knownTarget === target);
|
|
63
|
-
records.push({
|
|
64
|
-
event,
|
|
65
|
-
matcher,
|
|
66
|
-
ordinal,
|
|
67
|
-
role: isDispatcher ? "dispatcher" : "hook",
|
|
68
|
-
scope,
|
|
69
|
-
target,
|
|
70
|
-
timeoutSeconds: Number.isInteger(hook.timeout) ? hook.timeout : null,
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function assignmentBody(source, rosterName) {
|
|
76
|
-
const rosterOffset = source.lastIndexOf(rosterName);
|
|
77
|
-
if (rosterOffset < 0) throw new Error(`missing roster ${rosterName}`);
|
|
78
|
-
const equalsOffset = source.indexOf("=", rosterOffset);
|
|
79
|
-
const openOffset = source.indexOf("(", equalsOffset);
|
|
80
|
-
if (equalsOffset < 0 || openOffset < 0) throw new Error(`invalid roster ${rosterName}`);
|
|
81
|
-
const closeOffset = matchingParenthesis(source, openOffset);
|
|
82
|
-
return source.slice(openOffset + 1, closeOffset);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function matchingParenthesis(source, openOffset) {
|
|
86
|
-
let depth = 0;
|
|
87
|
-
let quote = "";
|
|
88
|
-
for (let offset = openOffset; offset < source.length; offset += 1) {
|
|
89
|
-
const character = source[offset];
|
|
90
|
-
const previous = source[offset - 1];
|
|
91
|
-
if (quote && character === quote && previous !== "\\") quote = "";
|
|
92
|
-
else if (!quote && (character === '"' || character === "'")) quote = character;
|
|
93
|
-
else if (!quote && character === "(") depth += 1;
|
|
94
|
-
else if (!quote && character === ")") depth -= 1;
|
|
95
|
-
if (depth === 0) return offset;
|
|
96
|
-
}
|
|
97
|
-
throw new Error("unclosed roster");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function topLevelEntries(source) {
|
|
101
|
-
const entries = [];
|
|
102
|
-
let depth = 0;
|
|
103
|
-
let quote = "";
|
|
104
|
-
let start = 0;
|
|
105
|
-
for (let offset = 0; offset < source.length; offset += 1) {
|
|
106
|
-
const character = source[offset];
|
|
107
|
-
const previous = source[offset - 1];
|
|
108
|
-
if (quote && character === quote && previous !== "\\") quote = "";
|
|
109
|
-
else if (!quote && (character === '"' || character === "'")) quote = character;
|
|
110
|
-
else if (!quote && /[([{]/u.test(character)) depth += 1;
|
|
111
|
-
else if (!quote && /[\])}]/u.test(character)) depth -= 1;
|
|
112
|
-
else if (!quote && character === "," && depth === 0) {
|
|
113
|
-
entries.push(source.slice(start, offset));
|
|
114
|
-
start = offset + 1;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
entries.push(source.slice(start));
|
|
118
|
-
return entries.filter((entry) => entry.trim());
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function toolMatchers(source) {
|
|
122
|
-
const matchers = new Map();
|
|
123
|
-
for (const match of source.matchAll(/\b([A-Z][A-Z0-9_]*_TOOL_NAME)\s*=\s*["']([^"']+)["']/gu)) {
|
|
124
|
-
matchers.set(match[1], new Set([match[2]]));
|
|
125
|
-
}
|
|
126
|
-
const collections = [...source.matchAll(/\b([A-Z][A-Z0-9_]*_TOOL_NAMES)\s*(?::[^=]+)?=\s*frozenset\s*\(/gu)];
|
|
127
|
-
for (const collection of collections) {
|
|
128
|
-
const openOffset = source.indexOf("(", collection.index + collection[0].indexOf("frozenset"));
|
|
129
|
-
const body = source.slice(openOffset + 1, matchingParenthesis(source, openOffset));
|
|
130
|
-
const names = new Set();
|
|
131
|
-
for (const token of body.match(/\b[A-Z][A-Z0-9_]+\b/gu) ?? []) {
|
|
132
|
-
for (const toolName of matchers.get(token) ?? []) names.add(toolName);
|
|
133
|
-
}
|
|
134
|
-
matchers.set(collection[1], names);
|
|
135
|
-
}
|
|
136
|
-
return matchers;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function rosterEntries(hooksRoot, constantsPath, rosterName) {
|
|
140
|
-
const source = readFileSync(path.join(hooksRoot, constantsPath), "utf8");
|
|
141
|
-
const body = assignmentBody(source, rosterName);
|
|
142
|
-
const matchers = toolMatchers(source);
|
|
143
|
-
return topLevelEntries(body).map((expression) => {
|
|
144
|
-
const targetMatch = /["']([^"']+\.py)["']/u.exec(expression);
|
|
145
|
-
if (!targetMatch) throw new Error(`invalid roster entry ${rosterName}`);
|
|
146
|
-
const names = new Set();
|
|
147
|
-
for (const token of expression.match(/\b[A-Z][A-Z0-9_]+\b/gu) ?? []) {
|
|
148
|
-
for (const toolName of matchers.get(token) ?? []) names.add(toolName);
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
matcher: names.size ? [...names].sort().join("|") : null,
|
|
152
|
-
target: `script:${targetMatch[1].replaceAll("\\", "/")}`,
|
|
153
|
-
};
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function expandHosted(directRecords, hooksRoot) {
|
|
158
|
-
const parents = new Map(
|
|
159
|
-
directRecords.filter(({ role }) => role === "dispatcher").map((record) => [record.target, record]),
|
|
160
|
-
);
|
|
161
|
-
const hosted = [];
|
|
162
|
-
for (const [dispatcherTarget, constantsPath, rosterName] of dispatcherSpecs) {
|
|
163
|
-
const parent = parents.get(dispatcherTarget);
|
|
164
|
-
if (!parent) continue;
|
|
165
|
-
const entries = rosterEntries(hooksRoot, constantsPath, rosterName);
|
|
166
|
-
for (const entry of entries) {
|
|
167
|
-
hosted.push({
|
|
168
|
-
event: parent.event,
|
|
169
|
-
matcher: entry.matcher ?? parent.matcher,
|
|
170
|
-
ordinal: hosted.length,
|
|
171
|
-
parentTarget: parent.target,
|
|
172
|
-
role: "hosted",
|
|
173
|
-
scope: parent.scope,
|
|
174
|
-
target: entry.target,
|
|
175
|
-
timeoutSeconds: parent.timeoutSeconds,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return hosted;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function logicalRecords(directRecords, hostedRecords) {
|
|
183
|
-
return [...directRecords.filter(({ role }) => role !== "dispatcher"), ...hostedRecords].sort(recordSort);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function recordSort(left, right) {
|
|
187
|
-
const leftKey = [left.scope, left.event, left.matcher, left.target, left.role, left.ordinal].join("\0");
|
|
188
|
-
const rightKey = [right.scope, right.event, right.matcher, right.target, right.role, right.ordinal].join("\0");
|
|
189
|
-
return leftKey.localeCompare(rightKey);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function duplicateFindings(records, scope) {
|
|
193
|
-
const counts = new Map();
|
|
194
|
-
for (const record of records) {
|
|
195
|
-
const key = [record.event, record.matcher, record.target].join("\0");
|
|
196
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
197
|
-
}
|
|
198
|
-
return [...counts.entries()]
|
|
199
|
-
.filter(([, count]) => count > 1)
|
|
200
|
-
.map(([key, count]) => ({
|
|
201
|
-
code: "DUPLICATE_EFFECTIVE_EXECUTION",
|
|
202
|
-
message: `The same event, matcher, and target run ${count} times.`,
|
|
203
|
-
scope,
|
|
204
|
-
severity: "error",
|
|
205
|
-
target: key.split("\0").at(-1),
|
|
206
|
-
}));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function missingTargetFindings(records, hooksRoot, scope) {
|
|
210
|
-
return records
|
|
211
|
-
.filter(({ target }) => target.startsWith("script:"))
|
|
212
|
-
.filter(({ target }) => !existsSync(path.join(hooksRoot, target.slice("script:".length))))
|
|
213
|
-
.map(({ target }) => ({
|
|
214
|
-
code: "MISSING_HOOK_TARGET",
|
|
215
|
-
message: "The registered script is missing from the installed hook tree.",
|
|
216
|
-
scope,
|
|
217
|
-
severity: "error",
|
|
218
|
-
target,
|
|
219
|
-
}));
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function missingManagedFindings(expectedRecords, installedRecords, scope) {
|
|
223
|
-
const installedIdentities = new Set(installedRecords.map(recordIdentity));
|
|
224
|
-
return expectedRecords
|
|
225
|
-
.filter((record) => !installedIdentities.has(recordIdentity(record)))
|
|
226
|
-
.map(({ target }) => ({
|
|
227
|
-
code: "MISSING_MANAGED_REGISTRATION",
|
|
228
|
-
message: "The installed configuration is missing a canonical registration.",
|
|
229
|
-
scope,
|
|
230
|
-
severity: "error",
|
|
231
|
-
target,
|
|
232
|
-
}));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function recordIdentity({ event, matcher, target }) {
|
|
236
|
-
return [event, matcher, target].join("\0");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function readLifecycle(catalogPath) {
|
|
240
|
-
if (!catalogPath) return [];
|
|
241
|
-
const parsed = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
242
|
-
return Object.entries(parsed.hooks ?? {}).map(([target, decision]) => {
|
|
243
|
-
const safeTarget = /^(?:external:[A-Za-z0-9_.:-]+|git:(?:post-commit|pre-commit|pre-push)|script:[A-Za-z0-9_./-]+)$/u;
|
|
244
|
-
if (!safeTarget.test(target) || !lifecycleNames.has(decision?.lifecycle) || !decision.reason) {
|
|
245
|
-
throw new Error("invalid lifecycle entry");
|
|
246
|
-
}
|
|
247
|
-
return { lifecycle: decision.lifecycle, target };
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function lifecycleFindings(records, lifecycle) {
|
|
252
|
-
const targets = new Set(records.map(({ target }) => target));
|
|
253
|
-
const classified = new Set(lifecycle.map(({ target }) => target));
|
|
254
|
-
const missing = [...targets].filter((target) => !classified.has(target));
|
|
255
|
-
const stale = [...classified].filter((target) => !targets.has(target));
|
|
256
|
-
return [
|
|
257
|
-
...missing.map((target) => ({
|
|
258
|
-
code: "UNCLASSIFIED_HOOK",
|
|
259
|
-
message: "The effective hook has no lifecycle decision.",
|
|
260
|
-
scope: "catalog",
|
|
261
|
-
severity: "error",
|
|
262
|
-
target,
|
|
263
|
-
})),
|
|
264
|
-
...stale.map((target) => ({
|
|
265
|
-
code: "STALE_LIFECYCLE_ENTRY",
|
|
266
|
-
message: "The lifecycle target is absent from the canonical graph.",
|
|
267
|
-
scope: "catalog",
|
|
268
|
-
severity: "warning",
|
|
269
|
-
target,
|
|
270
|
-
})),
|
|
271
|
-
];
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function nonblockingFindings(records, lifecycle) {
|
|
275
|
-
const lifecycleByTarget = new Map(
|
|
276
|
-
lifecycle.map(({ target, lifecycle: lifecycleName }) => [target, lifecycleName]),
|
|
277
|
-
);
|
|
278
|
-
const uniqueTargets = [...new Set(records.map(({ target }) => target))];
|
|
279
|
-
const lifecycleMismatches = uniqueTargets
|
|
280
|
-
.filter((target) => !target.startsWith("git:") && lifecycleByTarget.has(target))
|
|
281
|
-
.filter((target) => lifecycleByTarget.get(target) !== nonblockingLifecycle)
|
|
282
|
-
.map((target) => ({
|
|
283
|
-
code: "NONBLOCKING_LIFECYCLE_MISMATCH",
|
|
284
|
-
message: "The registration is not eligible for nonblocking automation.",
|
|
285
|
-
scope: "catalog",
|
|
286
|
-
severity: "error",
|
|
287
|
-
target,
|
|
288
|
-
}));
|
|
289
|
-
const blockingGitFindings = records
|
|
290
|
-
.filter(({ event, scope }) => scope === "git" && blockingGitEvents.has(event))
|
|
291
|
-
.map(({ target }) => ({
|
|
292
|
-
code: "NATIVE_GIT_BLOCKING_HOOK",
|
|
293
|
-
message: "A native Git blocking hook remains active.",
|
|
294
|
-
scope: "git",
|
|
295
|
-
severity: "error",
|
|
296
|
-
target,
|
|
297
|
-
}));
|
|
298
|
-
return [...lifecycleMismatches, ...blockingGitFindings];
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function registrationEligibility(records, lifecycle, isRequired) {
|
|
302
|
-
if (!isRequired) {
|
|
303
|
-
return { status: "not_checked", runtimeProof: "not_assessed" };
|
|
304
|
-
}
|
|
305
|
-
const lifecycleByTarget = new Map(
|
|
306
|
-
lifecycle.map(({ target, lifecycle: lifecycleName }) => [target, lifecycleName]),
|
|
307
|
-
);
|
|
308
|
-
const uniqueTargets = [...new Set(records.map(({ target }) => target))];
|
|
309
|
-
const hasUnclassifiedTarget = uniqueTargets.some((target) => !lifecycleByTarget.has(target));
|
|
310
|
-
const hasIneligibleTarget = uniqueTargets.some(
|
|
311
|
-
(target) => lifecycleByTarget.get(target) && lifecycleByTarget.get(target) !== nonblockingLifecycle,
|
|
312
|
-
);
|
|
313
|
-
const hasBlockingGitHook = records.some(
|
|
314
|
-
({ event, scope }) => scope === "git" && blockingGitEvents.has(event),
|
|
315
|
-
);
|
|
316
|
-
const isEligible = !hasUnclassifiedTarget && !hasIneligibleTarget && !hasBlockingGitHook;
|
|
317
|
-
return {
|
|
318
|
-
status: isEligible ? "eligible" : "ineligible",
|
|
319
|
-
runtimeProof: "not_assessed",
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function readInstalled(configPath, hooksRoot, scope) {
|
|
324
|
-
if (!existsSync(configPath)) return { findings: [], logical: [], records: [] };
|
|
325
|
-
const records = readDirect(configPath, scope);
|
|
326
|
-
const hosted = expandHosted(records, hooksRoot);
|
|
327
|
-
const logical = logicalRecords(records, hosted);
|
|
328
|
-
return {
|
|
329
|
-
findings: [
|
|
330
|
-
...duplicateFindings(logical, scope),
|
|
331
|
-
...missingTargetFindings([...records, ...hosted], hooksRoot, scope),
|
|
332
|
-
],
|
|
333
|
-
logical,
|
|
334
|
-
records,
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function activeGitHooks(repositoryRoot) {
|
|
339
|
-
const gitQuery = spawnSync("git", ["rev-parse", "--git-path", "hooks"], {
|
|
340
|
-
cwd: repositoryRoot,
|
|
341
|
-
encoding: "utf8",
|
|
342
|
-
shell: false,
|
|
343
|
-
timeout: 10_000,
|
|
344
|
-
});
|
|
345
|
-
if (gitQuery.status !== 0) return [];
|
|
346
|
-
const hooksPath = gitQuery.stdout?.trim();
|
|
347
|
-
if (!hooksPath) return [];
|
|
348
|
-
const hooksRoot = path.isAbsolute(hooksPath) ? hooksPath : path.resolve(repositoryRoot, hooksPath);
|
|
349
|
-
return KNOWN_GIT_HOOK_NAMES
|
|
350
|
-
.filter((name) => existsSync(path.join(hooksRoot, name)))
|
|
351
|
-
.map((name, ordinal) => ({
|
|
352
|
-
event: name,
|
|
353
|
-
matcher: "*",
|
|
354
|
-
ordinal,
|
|
355
|
-
role: "native",
|
|
356
|
-
scope: "git",
|
|
357
|
-
target: `git:${name}`,
|
|
358
|
-
timeoutSeconds: null,
|
|
359
|
-
}));
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function knownGitHooks() {
|
|
363
|
-
return KNOWN_GIT_HOOK_NAMES.map((event, ordinal) => ({
|
|
364
|
-
event,
|
|
365
|
-
matcher: "*",
|
|
366
|
-
ordinal,
|
|
367
|
-
role: "native",
|
|
368
|
-
scope: "git",
|
|
369
|
-
target: `git:${event}`,
|
|
370
|
-
timeoutSeconds: null,
|
|
371
|
-
}));
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
export function auditHooks(options) {
|
|
375
|
-
const repositoryRoot = path.resolve(options.repositoryRoot);
|
|
376
|
-
const hooksRoot = path.join(repositoryRoot, "packages", "claude-dev-env", "hooks");
|
|
377
|
-
const direct = readDirect(path.join(hooksRoot, "hooks.json"), "canonical");
|
|
378
|
-
const hosted = expandHosted(direct, hooksRoot);
|
|
379
|
-
const logical = logicalRecords(direct, hosted);
|
|
380
|
-
const knownGit = knownGitHooks();
|
|
381
|
-
const lifecycle = readLifecycle(options.catalogPath);
|
|
382
|
-
const dispatcherCount = direct.filter(({ role }) => role === "dispatcher").length;
|
|
383
|
-
const installed = options.includeInstalled
|
|
384
|
-
? installedAudit(options.homeDirectory ?? homedir(), repositoryRoot, logical)
|
|
385
|
-
: { claudeCount: 0, codexCount: 0, findings: [], git: [], logical: [], records: [] };
|
|
386
|
-
const allLogical = [...logical, ...installed.logical, ...installed.git];
|
|
387
|
-
const isNonblockingRequired = options.requireNonblocking === true;
|
|
388
|
-
const findings = [
|
|
389
|
-
...duplicateFindings(logical, "canonical"),
|
|
390
|
-
...(options.catalogPath || isNonblockingRequired ? lifecycleFindings(allLogical, lifecycle) : []),
|
|
391
|
-
...(isNonblockingRequired ? nonblockingFindings(allLogical, lifecycle) : []),
|
|
392
|
-
...installed.findings,
|
|
393
|
-
].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
394
|
-
const targets = new Set(allLogical.map(({ target }) => target));
|
|
395
|
-
const classified = new Set(lifecycle.map(({ target }) => target));
|
|
396
|
-
return {
|
|
397
|
-
directRegistrations: direct.sort(recordSort),
|
|
398
|
-
findings,
|
|
399
|
-
hostedRegistrations: hosted.sort(recordSort),
|
|
400
|
-
installedRegistrations: installed.records.sort(recordSort),
|
|
401
|
-
installedLogicalRegistrations: installed.logical.sort(recordSort),
|
|
402
|
-
gitRegistrations: installed.git,
|
|
403
|
-
knownGitRegistrations: knownGit,
|
|
404
|
-
lifecycle,
|
|
405
|
-
logicalRegistrations: logical,
|
|
406
|
-
schemaVersion: 1,
|
|
407
|
-
summary: {
|
|
408
|
-
claudeDirectCount: installed.claudeCount,
|
|
409
|
-
codexDirectCount: installed.codexCount,
|
|
410
|
-
directCount: direct.length,
|
|
411
|
-
dispatcherCount,
|
|
412
|
-
effectiveCount: hosted.length,
|
|
413
|
-
findingCount: findings.length,
|
|
414
|
-
gitHookCount: installed.git.length,
|
|
415
|
-
knownGitHookCount: knownGit.length,
|
|
416
|
-
lifecycleCount: lifecycle.length,
|
|
417
|
-
logicalAssociationCount: logical.length,
|
|
418
|
-
unclassifiedCount: options.catalogPath || isNonblockingRequired
|
|
419
|
-
? [...targets].filter((target) => !classified.has(target)).length
|
|
420
|
-
: 0,
|
|
421
|
-
uniqueTargetCount: targets.size,
|
|
422
|
-
},
|
|
423
|
-
registrationEligibility: registrationEligibility(allLogical, lifecycle, isNonblockingRequired),
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function installedAudit(homeDirectory, repositoryRoot, canonicalLogical) {
|
|
428
|
-
const claude = readInstalled(
|
|
429
|
-
path.join(homeDirectory, ".claude", "settings.json"),
|
|
430
|
-
path.join(homeDirectory, ".claude", "hooks"),
|
|
431
|
-
"claude",
|
|
432
|
-
);
|
|
433
|
-
const codex = readInstalled(
|
|
434
|
-
path.join(homeDirectory, ".codex", "hooks.json"),
|
|
435
|
-
path.join(homeDirectory, ".codex", "hooks"),
|
|
436
|
-
"codex",
|
|
437
|
-
);
|
|
438
|
-
const git = activeGitHooks(repositoryRoot);
|
|
439
|
-
return {
|
|
440
|
-
claudeCount: claude.records.length,
|
|
441
|
-
codexCount: codex.records.length,
|
|
442
|
-
findings: [
|
|
443
|
-
...claude.findings,
|
|
444
|
-
...codex.findings,
|
|
445
|
-
...missingManagedFindings(canonicalLogical, claude.logical, "claude"),
|
|
446
|
-
...missingManagedFindings(canonicalLogical, codex.logical, "codex"),
|
|
447
|
-
],
|
|
448
|
-
git,
|
|
449
|
-
logical: [...claude.logical, ...codex.logical],
|
|
450
|
-
records: [...claude.records, ...codex.records],
|
|
451
|
-
};
|
|
452
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
mkdirSync,
|
|
5
|
-
mkdtempSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import path from "node:path";
|
|
12
|
-
import test from "node:test";
|
|
13
|
-
import { fileURLToPath } from "node:url";
|
|
14
|
-
|
|
15
|
-
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
-
const auditCliPath = path.join(packageRoot, "audit-hooks-cli.mjs");
|
|
17
|
-
|
|
18
|
-
function writeJson(filePath, jsonDocument) {
|
|
19
|
-
writeFileSync(filePath, JSON.stringify(jsonDocument), "utf8");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function createAuditFixture({
|
|
23
|
-
canonicalHooks = {},
|
|
24
|
-
claudeHooks = { hooks: {} },
|
|
25
|
-
codexHooks = { hooks: {} },
|
|
26
|
-
} = {}) {
|
|
27
|
-
const fixtureRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-cli-"));
|
|
28
|
-
const canonicalHooksRoot = path.join(fixtureRoot, "packages", "claude-dev-env", "hooks");
|
|
29
|
-
const homeDirectory = path.join(fixtureRoot, "home");
|
|
30
|
-
mkdirSync(canonicalHooksRoot, { recursive: true });
|
|
31
|
-
mkdirSync(path.join(homeDirectory, ".claude", "hooks"), { recursive: true });
|
|
32
|
-
mkdirSync(path.join(homeDirectory, ".codex", "hooks"), { recursive: true });
|
|
33
|
-
writeJson(path.join(canonicalHooksRoot, "hooks.json"), { hooks: canonicalHooks });
|
|
34
|
-
writeJson(path.join(homeDirectory, ".claude", "settings.json"), claudeHooks);
|
|
35
|
-
writeJson(path.join(homeDirectory, ".codex", "hooks.json"), codexHooks);
|
|
36
|
-
return { fixtureRoot, homeDirectory };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function runAuditCli(allArguments) {
|
|
40
|
-
const childProcess = spawnSync(process.execPath, [auditCliPath, ...allArguments], {
|
|
41
|
-
cwd: packageRoot,
|
|
42
|
-
encoding: "utf8",
|
|
43
|
-
env: { ...process.env, HOME: path.join(tmpdir(), "unused-audit-cli-home") },
|
|
44
|
-
shell: false,
|
|
45
|
-
});
|
|
46
|
-
assert.equal(childProcess.error, undefined);
|
|
47
|
-
return childProcess;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
test("writes a JSON audit report to stdout through the CLI", () => {
|
|
51
|
-
const fixture = createAuditFixture({
|
|
52
|
-
canonicalHooks: {
|
|
53
|
-
PreToolUse: [{
|
|
54
|
-
matcher: "Write",
|
|
55
|
-
hooks: [{
|
|
56
|
-
type: "command",
|
|
57
|
-
command: "python3 " + "$" + "{CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py",
|
|
58
|
-
}],
|
|
59
|
-
}],
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
try {
|
|
63
|
-
const childProcess = runAuditCli([
|
|
64
|
-
"--format",
|
|
65
|
-
"json",
|
|
66
|
-
"--repository-root",
|
|
67
|
-
fixture.fixtureRoot,
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
assert.equal(childProcess.status, 0);
|
|
71
|
-
assert.equal(childProcess.stderr, "");
|
|
72
|
-
const report = JSON.parse(childProcess.stdout);
|
|
73
|
-
assert.equal(report.summary.directCount, 1);
|
|
74
|
-
assert.equal(report.registrationEligibility.status, "not_checked");
|
|
75
|
-
} finally {
|
|
76
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("writes the JSON audit report to the requested file through the CLI", () => {
|
|
81
|
-
const fixture = createAuditFixture();
|
|
82
|
-
const reportPath = path.join(fixture.fixtureRoot, "audit-report.json");
|
|
83
|
-
try {
|
|
84
|
-
const childProcess = runAuditCli([
|
|
85
|
-
"--format",
|
|
86
|
-
"json",
|
|
87
|
-
"--output",
|
|
88
|
-
reportPath,
|
|
89
|
-
"--repository-root",
|
|
90
|
-
fixture.fixtureRoot,
|
|
91
|
-
]);
|
|
92
|
-
|
|
93
|
-
assert.equal(childProcess.status, 0);
|
|
94
|
-
assert.equal(childProcess.stdout, "");
|
|
95
|
-
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
96
|
-
assert.equal(report.summary.directCount, 0);
|
|
97
|
-
assert.equal(report.summary.findingCount, 0);
|
|
98
|
-
} finally {
|
|
99
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("returns a strict nonblocking failure through the CLI", () => {
|
|
104
|
-
const fixture = createAuditFixture({
|
|
105
|
-
claudeHooks: {
|
|
106
|
-
hooks: {
|
|
107
|
-
SessionStart: [{
|
|
108
|
-
hooks: [{ command: "node cleanup.mjs", type: "command" }],
|
|
109
|
-
}],
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
const catalogPath = path.join(fixture.fixtureRoot, "lifecycle.json");
|
|
114
|
-
writeJson(catalogPath, {
|
|
115
|
-
hooks: {
|
|
116
|
-
"external:cleanup.mjs": {
|
|
117
|
-
lifecycle: "delete",
|
|
118
|
-
reason: "The installed entry is retired.",
|
|
119
|
-
replacement: null,
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
try {
|
|
124
|
-
const childProcess = runAuditCli([
|
|
125
|
-
"--format",
|
|
126
|
-
"json",
|
|
127
|
-
"--catalog",
|
|
128
|
-
catalogPath,
|
|
129
|
-
"--home",
|
|
130
|
-
fixture.homeDirectory,
|
|
131
|
-
"--installed",
|
|
132
|
-
"--require-nonblocking",
|
|
133
|
-
"--repository-root",
|
|
134
|
-
fixture.fixtureRoot,
|
|
135
|
-
]);
|
|
136
|
-
|
|
137
|
-
assert.equal(childProcess.status, 1);
|
|
138
|
-
assert.equal(childProcess.stderr, "");
|
|
139
|
-
const report = JSON.parse(childProcess.stdout);
|
|
140
|
-
assert.equal(report.registrationEligibility.status, "ineligible");
|
|
141
|
-
assert.equal(
|
|
142
|
-
report.findings.some(({ code, target }) =>
|
|
143
|
-
code === "NONBLOCKING_LIFECYCLE_MISMATCH" && target === "external:cleanup.mjs",
|
|
144
|
-
),
|
|
145
|
-
true,
|
|
146
|
-
);
|
|
147
|
-
} finally {
|
|
148
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
149
|
-
}
|
|
150
|
-
});
|
|
@@ -1,497 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import test from "node:test";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
|
|
9
|
-
import { auditHooks } from "../audit-hooks.mjs";
|
|
10
|
-
|
|
11
|
-
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
|
|
12
|
-
|
|
13
|
-
function writeJson(filePath, document) {
|
|
14
|
-
writeFileSync(filePath, JSON.stringify(document), "utf8");
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function createAuditFixture({ canonicalHooks = {}, claudeHooks = { hooks: {} }, codexHooks = { hooks: {} } } = {}) {
|
|
18
|
-
const fixtureRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-"));
|
|
19
|
-
const canonicalHooksRoot = path.join(fixtureRoot, "packages", "claude-dev-env", "hooks");
|
|
20
|
-
const homeDirectory = path.join(fixtureRoot, "home");
|
|
21
|
-
mkdirSync(canonicalHooksRoot, { recursive: true });
|
|
22
|
-
mkdirSync(path.join(homeDirectory, ".claude", "hooks"), { recursive: true });
|
|
23
|
-
mkdirSync(path.join(homeDirectory, ".codex", "hooks"), { recursive: true });
|
|
24
|
-
writeJson(path.join(canonicalHooksRoot, "hooks.json"), { hooks: canonicalHooks });
|
|
25
|
-
writeJson(path.join(homeDirectory, ".claude", "settings.json"), claudeHooks);
|
|
26
|
-
writeJson(path.join(homeDirectory, ".codex", "hooks.json"), codexHooks);
|
|
27
|
-
return { fixtureRoot, homeDirectory, canonicalHooksRoot };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function writeHookFile(hooksRoot, relativePath) {
|
|
31
|
-
const hookPath = path.join(hooksRoot, relativePath);
|
|
32
|
-
mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
33
|
-
writeFileSync(hookPath, "", "utf8");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function runGit(repositoryRoot, argumentsList, environment) {
|
|
37
|
-
const command = spawnSync("git", argumentsList, {
|
|
38
|
-
cwd: repositoryRoot,
|
|
39
|
-
encoding: "utf8",
|
|
40
|
-
env: environment,
|
|
41
|
-
shell: false,
|
|
42
|
-
});
|
|
43
|
-
assert.equal(command.status, 0, command.stderr);
|
|
44
|
-
return command;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
test("reports the canonical execution graph", () => {
|
|
48
|
-
const fixture = createAuditFixture({
|
|
49
|
-
canonicalHooks: {
|
|
50
|
-
PreToolUse: [{
|
|
51
|
-
matcher: "Write",
|
|
52
|
-
hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py" }],
|
|
53
|
-
}],
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
try {
|
|
57
|
-
const report = auditHooks({ repositoryRoot: fixture.fixtureRoot });
|
|
58
|
-
assert.equal(report.summary.directCount, 1);
|
|
59
|
-
assert.equal(report.summary.dispatcherCount, 0);
|
|
60
|
-
assert.equal(report.summary.effectiveCount, 0);
|
|
61
|
-
assert.equal(report.summary.logicalAssociationCount, 1);
|
|
62
|
-
} finally {
|
|
63
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("does not expand the retired stop dispatcher", () => {
|
|
68
|
-
const fixture = createAuditFixture({
|
|
69
|
-
canonicalHooks: {
|
|
70
|
-
Stop: [{
|
|
71
|
-
hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/stop_dispatcher.py" }],
|
|
72
|
-
}],
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
const constantsPath = path.join(
|
|
76
|
-
fixture.canonicalHooksRoot,
|
|
77
|
-
"hooks_constants",
|
|
78
|
-
"stop_dispatcher_constants.py",
|
|
79
|
-
);
|
|
80
|
-
writeHookFile(fixture.canonicalHooksRoot, "blocking/stop_dispatcher.py");
|
|
81
|
-
mkdirSync(path.dirname(constantsPath), { recursive: true });
|
|
82
|
-
writeFileSync(constantsPath, "ALL_STOP_HOSTED_HOOK_PATHS = ()\n", "utf8");
|
|
83
|
-
try {
|
|
84
|
-
const report = auditHooks({ repositoryRoot: fixture.fixtureRoot });
|
|
85
|
-
assert.equal(report.summary.dispatcherCount, 0);
|
|
86
|
-
assert.equal(report.hostedRegistrations.length, 0);
|
|
87
|
-
assert.equal(report.directRegistrations[0].target, "script:blocking/stop_dispatcher.py");
|
|
88
|
-
} finally {
|
|
89
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("renders deterministic private JSON", () => {
|
|
94
|
-
const report = auditHooks({ repositoryRoot });
|
|
95
|
-
const first = JSON.stringify(report);
|
|
96
|
-
const second = JSON.stringify(report);
|
|
97
|
-
assert.equal(first, second);
|
|
98
|
-
assert.equal(first.includes(repositoryRoot), false);
|
|
99
|
-
assert.equal(first.includes("python3 ${CLAUDE_PLUGIN_ROOT}"), false);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("does not compare the live graph with retired baseline counts", () => {
|
|
103
|
-
const report = auditHooks({ repositoryRoot });
|
|
104
|
-
assert.equal(report.summary.directCount, report.directRegistrations.length);
|
|
105
|
-
assert.equal(report.summary.effectiveCount, report.hostedRegistrations.length);
|
|
106
|
-
assert.equal(report.summary.logicalAssociationCount, report.logicalRegistrations.length);
|
|
107
|
-
assert.ok(report.summary.directCount > 0);
|
|
108
|
-
assert.ok(report.summary.effectiveCount > 0);
|
|
109
|
-
assert.ok(report.summary.logicalAssociationCount > report.summary.directCount);
|
|
110
|
-
assert.equal(report.findings.some(({ code }) => code === "DIRECT_COUNT_DRIFT"), false);
|
|
111
|
-
assert.equal(report.findings.some(({ code }) => code === "HOSTED_COUNT_DRIFT"), false);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("does not execute registered commands", () => {
|
|
115
|
-
const fixture = createAuditFixture({
|
|
116
|
-
canonicalHooks: {
|
|
117
|
-
PreToolUse: [{
|
|
118
|
-
hooks: [{
|
|
119
|
-
command: "node -e \"require('node:fs').writeFileSync('audit-executed', 'yes')\"",
|
|
120
|
-
type: "command",
|
|
121
|
-
}],
|
|
122
|
-
}],
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
try {
|
|
126
|
-
auditHooks({ repositoryRoot: fixture.fixtureRoot });
|
|
127
|
-
assert.equal(existsSync(path.join(fixture.fixtureRoot, "audit-executed")), false);
|
|
128
|
-
} finally {
|
|
129
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("reports every unclassified target", () => {
|
|
134
|
-
const temporaryRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-"));
|
|
135
|
-
const catalogPath = path.join(temporaryRoot, "lifecycle.json");
|
|
136
|
-
writeFileSync(catalogPath, '{"schema_version":1,"hooks":{}}', "utf8");
|
|
137
|
-
try {
|
|
138
|
-
const report = auditHooks({ catalogPath, repositoryRoot });
|
|
139
|
-
assert.equal(report.summary.unclassifiedCount, report.summary.uniqueTargetCount);
|
|
140
|
-
assert.equal(report.findings.some(({ code }) => code === "UNCLASSIFIED_HOOK"), true);
|
|
141
|
-
} finally {
|
|
142
|
-
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("reports canonical native Git hooks from the installer registry", () => {
|
|
147
|
-
const report = auditHooks({ repositoryRoot });
|
|
148
|
-
assert.deepEqual(
|
|
149
|
-
report.knownGitRegistrations.map(({ target }) => target),
|
|
150
|
-
["git:pre-commit", "git:pre-push", "git:post-commit"],
|
|
151
|
-
);
|
|
152
|
-
assert.equal(report.summary.knownGitHookCount, 3);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("keeps each hosted matcher as narrow as its roster", () => {
|
|
156
|
-
const fixture = createAuditFixture({
|
|
157
|
-
canonicalHooks: {
|
|
158
|
-
PreToolUse: [{
|
|
159
|
-
matcher: "Bash|Write",
|
|
160
|
-
hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py" }],
|
|
161
|
-
}],
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
const constantsRoot = path.join(fixture.canonicalHooksRoot, "hooks_constants");
|
|
165
|
-
writeHookFile(fixture.canonicalHooksRoot, "blocking/pre_tool_use_dispatcher.py");
|
|
166
|
-
mkdirSync(constantsRoot, { recursive: true });
|
|
167
|
-
writeFileSync(
|
|
168
|
-
path.join(constantsRoot, "pre_tool_use_dispatcher_constants.py"),
|
|
169
|
-
[
|
|
170
|
-
'BASH_TOOL_NAME = "Bash"',
|
|
171
|
-
"ALL_BASH_TOOL_NAMES = frozenset({BASH_TOOL_NAME})",
|
|
172
|
-
"ALL_HOSTED_HOOK_ENTRIES = (",
|
|
173
|
-
'HostedHookEntry(script_relative_path="blocking/shell_substitution_blocker.py", applicable_tool_names=ALL_BASH_TOOL_NAMES),',
|
|
174
|
-
")",
|
|
175
|
-
].join("\n"),
|
|
176
|
-
"utf8",
|
|
177
|
-
);
|
|
178
|
-
try {
|
|
179
|
-
const report = auditHooks({ repositoryRoot: fixture.fixtureRoot });
|
|
180
|
-
const shellGate = report.hostedRegistrations.find(({ target }) =>
|
|
181
|
-
target.endsWith("shell_substitution_blocker.py"),
|
|
182
|
-
);
|
|
183
|
-
assert.equal(shellGate.matcher, "Bash");
|
|
184
|
-
} finally {
|
|
185
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test("omits untrusted lifecycle prose from output", () => {
|
|
190
|
-
const temporaryRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-"));
|
|
191
|
-
const catalogPath = path.join(temporaryRoot, "lifecycle.json");
|
|
192
|
-
const privateText = "PRIVATE_TOKEN python3 C:/Users/example/private.py";
|
|
193
|
-
const catalog = {
|
|
194
|
-
hooks: {
|
|
195
|
-
"script:blocking/code_rules_enforcer.py": {
|
|
196
|
-
lifecycle: "move_to_linter",
|
|
197
|
-
reason: privateText,
|
|
198
|
-
replacement: privateText,
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
writeFileSync(catalogPath, JSON.stringify(catalog), "utf8");
|
|
203
|
-
try {
|
|
204
|
-
const rendered = JSON.stringify(auditHooks({ catalogPath, repositoryRoot }));
|
|
205
|
-
assert.equal(rendered.includes(privateText), false);
|
|
206
|
-
} finally {
|
|
207
|
-
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("classifies installed-only targets", () => {
|
|
212
|
-
const fixture = createAuditFixture();
|
|
213
|
-
const temporaryRoot = fixture.fixtureRoot;
|
|
214
|
-
const claudeRoot = path.join(fixture.homeDirectory, ".claude");
|
|
215
|
-
const codexRoot = path.join(fixture.homeDirectory, ".codex");
|
|
216
|
-
const installedOnly = {
|
|
217
|
-
hooks: {
|
|
218
|
-
SessionStart: [{ hooks: [{ command: "node cleanup.mjs", type: "command" }], matcher: "" }],
|
|
219
|
-
},
|
|
220
|
-
};
|
|
221
|
-
writeJson(path.join(claudeRoot, "settings.json"), installedOnly);
|
|
222
|
-
writeJson(path.join(codexRoot, "hooks.json"), { hooks: {} });
|
|
223
|
-
const catalogPath = path.join(temporaryRoot, "lifecycle.json");
|
|
224
|
-
writeFileSync(catalogPath, '{"hooks":{}}', "utf8");
|
|
225
|
-
try {
|
|
226
|
-
const report = auditHooks({
|
|
227
|
-
catalogPath,
|
|
228
|
-
homeDirectory: fixture.homeDirectory,
|
|
229
|
-
includeInstalled: true,
|
|
230
|
-
requireNonblocking: true,
|
|
231
|
-
repositoryRoot: temporaryRoot,
|
|
232
|
-
});
|
|
233
|
-
const missingTargets = new Set(
|
|
234
|
-
report.findings.filter(({ code }) => code === "UNCLASSIFIED_HOOK").map(({ target }) => target),
|
|
235
|
-
);
|
|
236
|
-
assert.equal(missingTargets.has("external:cleanup.mjs"), true);
|
|
237
|
-
} finally {
|
|
238
|
-
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test("reads dispatcher children from the installed hook tree", () => {
|
|
243
|
-
const fixture = createAuditFixture();
|
|
244
|
-
const temporaryRoot = fixture.fixtureRoot;
|
|
245
|
-
const claudeRoot = path.join(fixture.homeDirectory, ".claude");
|
|
246
|
-
const installedHooks = path.join(claudeRoot, "hooks");
|
|
247
|
-
const constantsRoot = path.join(installedHooks, "hooks_constants");
|
|
248
|
-
const blockingRoot = path.join(installedHooks, "blocking");
|
|
249
|
-
mkdirSync(constantsRoot, { recursive: true });
|
|
250
|
-
mkdirSync(blockingRoot, { recursive: true });
|
|
251
|
-
const config = {
|
|
252
|
-
hooks: {
|
|
253
|
-
PreToolUse: [{
|
|
254
|
-
hooks: [{
|
|
255
|
-
command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py",
|
|
256
|
-
type: "command",
|
|
257
|
-
}],
|
|
258
|
-
matcher: "Write",
|
|
259
|
-
}],
|
|
260
|
-
},
|
|
261
|
-
};
|
|
262
|
-
const roster = [
|
|
263
|
-
'WRITE_TOOL_NAME = "Write"',
|
|
264
|
-
"ALL_WRITE_TOOL_NAMES = frozenset({WRITE_TOOL_NAME})",
|
|
265
|
-
"ALL_HOSTED_HOOK_ENTRIES = (",
|
|
266
|
-
'HostedHookEntry(script_relative_path="blocking/installed_only.py", applicable_tool_names=ALL_WRITE_TOOL_NAMES),',
|
|
267
|
-
")",
|
|
268
|
-
].join("\n");
|
|
269
|
-
writeJson(path.join(claudeRoot, "settings.json"), config);
|
|
270
|
-
writeFileSync(path.join(constantsRoot, "pre_tool_use_dispatcher_constants.py"), roster, "utf8");
|
|
271
|
-
writeFileSync(path.join(blockingRoot, "pre_tool_use_dispatcher.py"), "", "utf8");
|
|
272
|
-
writeJson(path.join(fixture.homeDirectory, ".codex", "hooks.json"), { hooks: {} });
|
|
273
|
-
try {
|
|
274
|
-
const report = auditHooks({
|
|
275
|
-
homeDirectory: fixture.homeDirectory,
|
|
276
|
-
includeInstalled: true,
|
|
277
|
-
repositoryRoot: temporaryRoot,
|
|
278
|
-
});
|
|
279
|
-
assert.equal(
|
|
280
|
-
report.installedLogicalRegistrations.some(({ target }) => target.endsWith("installed_only.py")),
|
|
281
|
-
true,
|
|
282
|
-
);
|
|
283
|
-
assert.equal(
|
|
284
|
-
report.findings.some(({ code, target }) =>
|
|
285
|
-
code === "MISSING_HOOK_TARGET" && target === "script:blocking/installed_only.py",
|
|
286
|
-
),
|
|
287
|
-
true,
|
|
288
|
-
);
|
|
289
|
-
} finally {
|
|
290
|
-
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
test("reports missing installed dispatcher targets", () => {
|
|
295
|
-
const fixture = createAuditFixture({
|
|
296
|
-
claudeHooks: {
|
|
297
|
-
hooks: {
|
|
298
|
-
PreToolUse: [{
|
|
299
|
-
matcher: "Write",
|
|
300
|
-
hooks: [{
|
|
301
|
-
type: "command",
|
|
302
|
-
command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py",
|
|
303
|
-
}],
|
|
304
|
-
}],
|
|
305
|
-
},
|
|
306
|
-
},
|
|
307
|
-
});
|
|
308
|
-
const constantsPath = path.join(
|
|
309
|
-
fixture.homeDirectory,
|
|
310
|
-
".claude",
|
|
311
|
-
"hooks",
|
|
312
|
-
"hooks_constants",
|
|
313
|
-
"pre_tool_use_dispatcher_constants.py",
|
|
314
|
-
);
|
|
315
|
-
mkdirSync(path.dirname(constantsPath), { recursive: true });
|
|
316
|
-
writeFileSync(constantsPath, "ALL_HOSTED_HOOK_ENTRIES = ()\n", "utf8");
|
|
317
|
-
try {
|
|
318
|
-
const report = auditHooks({
|
|
319
|
-
homeDirectory: fixture.homeDirectory,
|
|
320
|
-
includeInstalled: true,
|
|
321
|
-
repositoryRoot: fixture.fixtureRoot,
|
|
322
|
-
});
|
|
323
|
-
assert.equal(
|
|
324
|
-
report.findings.some(({ code, scope, target }) =>
|
|
325
|
-
code === "MISSING_HOOK_TARGET" &&
|
|
326
|
-
scope === "claude" &&
|
|
327
|
-
target === "script:blocking/pre_tool_use_dispatcher.py",
|
|
328
|
-
),
|
|
329
|
-
true,
|
|
330
|
-
);
|
|
331
|
-
} finally {
|
|
332
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("rejects an installed-only registration without nonblocking lifecycle", () => {
|
|
337
|
-
const fixture = createAuditFixture({
|
|
338
|
-
claudeHooks: {
|
|
339
|
-
hooks: {
|
|
340
|
-
SessionStart: [{
|
|
341
|
-
hooks: [{ command: "node cleanup.mjs", type: "command" }],
|
|
342
|
-
}],
|
|
343
|
-
},
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
const catalogPath = path.join(fixture.fixtureRoot, "lifecycle.json");
|
|
347
|
-
writeJson(catalogPath, {
|
|
348
|
-
hooks: {
|
|
349
|
-
"external:cleanup.mjs": {
|
|
350
|
-
lifecycle: "delete",
|
|
351
|
-
reason: "The installed entry is retired.",
|
|
352
|
-
replacement: null,
|
|
353
|
-
},
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
try {
|
|
357
|
-
const report = auditHooks({
|
|
358
|
-
catalogPath,
|
|
359
|
-
homeDirectory: fixture.homeDirectory,
|
|
360
|
-
includeInstalled: true,
|
|
361
|
-
requireNonblocking: true,
|
|
362
|
-
repositoryRoot: fixture.fixtureRoot,
|
|
363
|
-
});
|
|
364
|
-
assert.equal(report.registrationEligibility.status, "ineligible");
|
|
365
|
-
assert.equal(
|
|
366
|
-
report.findings.some(({ code, target }) =>
|
|
367
|
-
code === "NONBLOCKING_LIFECYCLE_MISMATCH" && target === "external:cleanup.mjs",
|
|
368
|
-
),
|
|
369
|
-
true,
|
|
370
|
-
);
|
|
371
|
-
} finally {
|
|
372
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
test("checks canonical managed parity for Codex registrations", () => {
|
|
377
|
-
const fixture = createAuditFixture({
|
|
378
|
-
canonicalHooks: {
|
|
379
|
-
PreToolUse: [{
|
|
380
|
-
matcher: "Write",
|
|
381
|
-
hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py" }],
|
|
382
|
-
}],
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
try {
|
|
386
|
-
const report = auditHooks({
|
|
387
|
-
homeDirectory: fixture.homeDirectory,
|
|
388
|
-
includeInstalled: true,
|
|
389
|
-
repositoryRoot: fixture.fixtureRoot,
|
|
390
|
-
});
|
|
391
|
-
assert.equal(
|
|
392
|
-
report.findings.some(({ code, scope, target }) =>
|
|
393
|
-
code === "MISSING_MANAGED_REGISTRATION" &&
|
|
394
|
-
scope === "codex" &&
|
|
395
|
-
target === "script:blocking/write_existing_file_blocker.py",
|
|
396
|
-
),
|
|
397
|
-
true,
|
|
398
|
-
);
|
|
399
|
-
} finally {
|
|
400
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test("compares managed registrations by event, matcher, and target", () => {
|
|
405
|
-
const command = "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py";
|
|
406
|
-
const fixture = createAuditFixture({
|
|
407
|
-
canonicalHooks: {
|
|
408
|
-
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command }] }],
|
|
409
|
-
},
|
|
410
|
-
claudeHooks: {
|
|
411
|
-
hooks: {
|
|
412
|
-
PostToolUse: [{ matcher: "Read", hooks: [{ type: "command", command }] }],
|
|
413
|
-
},
|
|
414
|
-
},
|
|
415
|
-
codexHooks: {
|
|
416
|
-
hooks: {
|
|
417
|
-
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command }] }],
|
|
418
|
-
},
|
|
419
|
-
},
|
|
420
|
-
});
|
|
421
|
-
writeHookFile(path.join(fixture.homeDirectory, ".claude", "hooks"), "blocking/write_existing_file_blocker.py");
|
|
422
|
-
writeHookFile(path.join(fixture.homeDirectory, ".codex", "hooks"), "blocking/write_existing_file_blocker.py");
|
|
423
|
-
try {
|
|
424
|
-
const report = auditHooks({
|
|
425
|
-
homeDirectory: fixture.homeDirectory,
|
|
426
|
-
includeInstalled: true,
|
|
427
|
-
repositoryRoot: fixture.fixtureRoot,
|
|
428
|
-
});
|
|
429
|
-
assert.equal(
|
|
430
|
-
report.findings.some(({ code, scope, target }) =>
|
|
431
|
-
code === "MISSING_MANAGED_REGISTRATION" &&
|
|
432
|
-
scope === "claude" &&
|
|
433
|
-
target === "script:blocking/write_existing_file_blocker.py",
|
|
434
|
-
),
|
|
435
|
-
true,
|
|
436
|
-
);
|
|
437
|
-
assert.equal(
|
|
438
|
-
report.findings.some(({ code, scope }) =>
|
|
439
|
-
code === "MISSING_MANAGED_REGISTRATION" && scope === "codex",
|
|
440
|
-
),
|
|
441
|
-
false,
|
|
442
|
-
);
|
|
443
|
-
} finally {
|
|
444
|
-
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
445
|
-
}
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
test("finds default Git hooks from a worktree", () => {
|
|
449
|
-
const sourceRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-git-"));
|
|
450
|
-
const worktreeRoot = path.join(sourceRoot, "worktree");
|
|
451
|
-
const globalConfigPath = path.join(sourceRoot, "global-gitconfig");
|
|
452
|
-
writeFileSync(globalConfigPath, "", "utf8");
|
|
453
|
-
const environment = { ...process.env, GIT_CONFIG_GLOBAL: globalConfigPath, GIT_CONFIG_NOSYSTEM: "1" };
|
|
454
|
-
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
|
|
455
|
-
const previousNoSystem = process.env.GIT_CONFIG_NOSYSTEM;
|
|
456
|
-
process.env.GIT_CONFIG_GLOBAL = globalConfigPath;
|
|
457
|
-
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
|
458
|
-
try {
|
|
459
|
-
runGit(sourceRoot, ["init", "--quiet"], environment);
|
|
460
|
-
runGit(sourceRoot, ["config", "user.email", "hook-audit@example.invalid"], environment);
|
|
461
|
-
runGit(sourceRoot, ["config", "user.name", "Hook Audit"], environment);
|
|
462
|
-
runGit(sourceRoot, ["commit", "--quiet", "--allow-empty", "-m", "fixture"], environment);
|
|
463
|
-
runGit(sourceRoot, ["worktree", "add", "--quiet", worktreeRoot, "HEAD"], environment);
|
|
464
|
-
writeHookFile(path.join(sourceRoot, ".git", "hooks"), "pre-commit");
|
|
465
|
-
writeHookFile(path.join(sourceRoot, ".git", "hooks"), "post-commit");
|
|
466
|
-
const canonicalHooksRoot = path.join(worktreeRoot, "packages", "claude-dev-env", "hooks");
|
|
467
|
-
const homeDirectory = path.join(sourceRoot, "home");
|
|
468
|
-
mkdirSync(canonicalHooksRoot, { recursive: true });
|
|
469
|
-
mkdirSync(path.join(homeDirectory, ".claude", "hooks"), { recursive: true });
|
|
470
|
-
mkdirSync(path.join(homeDirectory, ".codex", "hooks"), { recursive: true });
|
|
471
|
-
writeJson(path.join(canonicalHooksRoot, "hooks.json"), { hooks: {} });
|
|
472
|
-
writeJson(path.join(homeDirectory, ".claude", "settings.json"), { hooks: {} });
|
|
473
|
-
writeJson(path.join(homeDirectory, ".codex", "hooks.json"), { hooks: {} });
|
|
474
|
-
const report = auditHooks({
|
|
475
|
-
homeDirectory,
|
|
476
|
-
includeInstalled: true,
|
|
477
|
-
requireNonblocking: true,
|
|
478
|
-
repositoryRoot: worktreeRoot,
|
|
479
|
-
});
|
|
480
|
-
assert.deepEqual(report.gitRegistrations.map(({ target }) => target), [
|
|
481
|
-
"git:pre-commit",
|
|
482
|
-
"git:post-commit",
|
|
483
|
-
]);
|
|
484
|
-
assert.equal(
|
|
485
|
-
report.findings.some(({ code, target }) =>
|
|
486
|
-
code === "NATIVE_GIT_BLOCKING_HOOK" && target === "git:pre-commit",
|
|
487
|
-
),
|
|
488
|
-
true,
|
|
489
|
-
);
|
|
490
|
-
} finally {
|
|
491
|
-
if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL;
|
|
492
|
-
else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
|
|
493
|
-
if (previousNoSystem === undefined) delete process.env.GIT_CONFIG_NOSYSTEM;
|
|
494
|
-
else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystem;
|
|
495
|
-
rmSync(sourceRoot, { force: true, recursive: true });
|
|
496
|
-
}
|
|
497
|
-
});
|