claude-dev-env 2.34.0 → 2.36.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/.agents/skills/autoconverge/SKILL.md +19 -15
- package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
- package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
- package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
- package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
- package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
- package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
- package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
- package/bin/cde.mjs +175 -0
- package/bin/cde.test.mjs +222 -0
- package/bin/install-constants.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +233 -2
- package/bin/install.mjs +49 -3
- package/bin/pstack-model-preferences.codex.json +23 -0
- package/bin/select-pstack-models.test.mjs +378 -0
- package/hooks/hooks_constants/tool_names.py +20 -0
- package/hooks/validators/fast_save_validators.py +63 -89
- package/hooks/validators/run_all_validators.py +16 -8
- package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
- package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
- package/package.json +3 -2
- package/rules/pstack-models.md +119 -0
- package/scripts/audit-hooks-cli.mjs +54 -0
- package/scripts/audit-hooks.mjs +397 -0
- package/scripts/cde_lint.py +258 -0
- package/scripts/policy_lint/__init__.py +41 -0
- package/scripts/policy_lint/adapter_configuration.py +91 -0
- package/scripts/policy_lint/adapter_detectors.py +328 -0
- package/scripts/policy_lint/adapter_pairing.py +145 -0
- package/scripts/policy_lint/adapter_support.py +97 -0
- package/scripts/policy_lint/adapters.py +352 -0
- package/scripts/policy_lint/config/__init__.py +1 -0
- package/scripts/policy_lint/config/constants.py +66 -0
- package/scripts/policy_lint/engine.py +224 -0
- package/scripts/policy_lint/model.py +387 -0
- package/scripts/policy_lint/registry.py +162 -0
- package/scripts/policy_lint/render.py +91 -0
- package/scripts/policy_lint/selection.py +372 -0
- package/scripts/policy_lint/selection_git.py +147 -0
- package/scripts/select_pstack_models.mjs +304 -0
- package/scripts/tests/test-audit-hooks.mjs +379 -0
- package/scripts/tests/test_cde_lint.py +374 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
- package/scripts/tests/test_policy_lint_selection.py +373 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const inheritanceAliases = new Set(['inherit-parent', 'auto']);
|
|
6
|
+
const hostNamePattern = /^[a-z0-9][a-z0-9_-]*$/;
|
|
7
|
+
const defaultSingleDelegationPanel = Object.freeze({
|
|
8
|
+
agentCount: 1,
|
|
9
|
+
requiresDistinctModels: false,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const allPstackRoleRequirements = Object.freeze({
|
|
13
|
+
'feature, refactoring': 'reliable code execution',
|
|
14
|
+
'bug-fix': 'careful diagnosis and code execution',
|
|
15
|
+
'perf-issue': 'systems reasoning and performance analysis',
|
|
16
|
+
'hillclimb': 'strong iterative judgment',
|
|
17
|
+
'judgment and prose': 'strong judgment and clear prose',
|
|
18
|
+
'hardest tasks': 'highest available reasoning capability',
|
|
19
|
+
'how explorer': 'fast codebase exploration',
|
|
20
|
+
'how explainer': 'clear technical synthesis',
|
|
21
|
+
'how critics': 'independent technical criticism',
|
|
22
|
+
'why investigators': 'fast evidence gathering',
|
|
23
|
+
'why synthesizer': 'strong evidence synthesis',
|
|
24
|
+
'reflect tooling': 'reliable tool and workflow analysis',
|
|
25
|
+
'reflect judgment, divergent, synthesizer': 'strong judgment and divergent analysis',
|
|
26
|
+
'arena runners': 'independent solution development',
|
|
27
|
+
'arena cross-judge pool': 'independent comparative judgment',
|
|
28
|
+
'swarm workers': 'fast bounded task execution',
|
|
29
|
+
'architect runners': 'independent architecture design',
|
|
30
|
+
'interrogate reviewers': 'independent adversarial review',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const defaultPanelByPstackRole = Object.freeze({
|
|
34
|
+
'how critics': Object.freeze({ agentCount: 3, requiresDistinctModels: false }),
|
|
35
|
+
'arena runners': Object.freeze({ agentCount: 3, requiresDistinctModels: true }),
|
|
36
|
+
'swarm workers': defaultSingleDelegationPanel,
|
|
37
|
+
'architect runners': Object.freeze({ agentCount: 2, requiresDistinctModels: true }),
|
|
38
|
+
'interrogate reviewers': Object.freeze({ agentCount: 3, requiresDistinctModels: true }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function selectPstackDelegation(input) {
|
|
42
|
+
validateSelectionInput(input);
|
|
43
|
+
const panel = resolvePanel(input);
|
|
44
|
+
validateDelegationIndex(input.delegationIndex, panel.agentCount);
|
|
45
|
+
const modelsByRole = readHostPreferences(input);
|
|
46
|
+
const allCandidates = collectCandidates(input, modelsByRole[input.role] ?? []);
|
|
47
|
+
const allSelectedCandidates = selectPanelCandidates(allCandidates, panel);
|
|
48
|
+
return buildSelection(input, panel, allSelectedCandidates);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateSelectionInput(input) {
|
|
52
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
53
|
+
throw new Error('selection input must be an object');
|
|
54
|
+
}
|
|
55
|
+
validateHost(input.host, input.inventoryHost);
|
|
56
|
+
validateDelegationIndex(input.delegationIndex);
|
|
57
|
+
requireStringArray(input.availableModelIds, 'available model ids');
|
|
58
|
+
requireStringArray(input.confirmedSuitableModelIds, 'confirmed suitable model ids');
|
|
59
|
+
validateRealModelIds(input.availableModelIds, 'available model ids');
|
|
60
|
+
validateParentFallback(input.parentFallback);
|
|
61
|
+
validateConfirmedModels(input);
|
|
62
|
+
if (typeof input.role !== 'string' || !Object.hasOwn(allPstackRoleRequirements, input.role)) {
|
|
63
|
+
throw new Error('role must name a portable pstack role');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateHost(host, inventoryHost) {
|
|
68
|
+
if (typeof host !== 'string' || !hostNamePattern.test(host)) {
|
|
69
|
+
throw new Error('host must be a lowercase host identifier');
|
|
70
|
+
}
|
|
71
|
+
if (inventoryHost !== host) {
|
|
72
|
+
throw new Error('inventory host must match selected host');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateDelegationIndex(delegationIndex, agentCount) {
|
|
77
|
+
if (!Number.isInteger(delegationIndex) || delegationIndex < 0) {
|
|
78
|
+
throw new Error('delegation index must be a non-negative integer');
|
|
79
|
+
}
|
|
80
|
+
if (agentCount !== undefined && delegationIndex >= agentCount) {
|
|
81
|
+
throw new Error('delegation index must identify an agent in the panel');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireStringArray(allEntries, label) {
|
|
86
|
+
if (!Array.isArray(allEntries) || allEntries.some(
|
|
87
|
+
eachEntry => typeof eachEntry !== 'string' || eachEntry === '',
|
|
88
|
+
)) {
|
|
89
|
+
throw new Error(label + ' must be an array of non-empty strings');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateRealModelIds(allModelIds, label) {
|
|
94
|
+
if (allModelIds.some(eachModelId => inheritanceAliases.has(eachModelId))) {
|
|
95
|
+
throw new Error(label + ' cannot contain inheritance aliases');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateParentFallback(parentFallback) {
|
|
100
|
+
const hasBooleanFlags = parentFallback
|
|
101
|
+
&& typeof parentFallback === 'object'
|
|
102
|
+
&& typeof parentFallback.isAllowed === 'boolean'
|
|
103
|
+
&& typeof parentFallback.hasMaterialCapabilityLoss === 'boolean';
|
|
104
|
+
if (!hasBooleanFlags) {
|
|
105
|
+
throw new Error('parent fallback flags must be booleans');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateConfirmedModels(input) {
|
|
110
|
+
validateRealModelIds(input.confirmedSuitableModelIds, 'confirmed suitable model ids');
|
|
111
|
+
const allAvailableModelIds = new Set(input.availableModelIds);
|
|
112
|
+
if (input.confirmedSuitableModelIds.some(
|
|
113
|
+
eachModelId => !allAvailableModelIds.has(eachModelId),
|
|
114
|
+
)) {
|
|
115
|
+
throw new Error('confirmed suitable models must be available');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolvePanel(input) {
|
|
120
|
+
const panel = input.panel
|
|
121
|
+
?? defaultPanelByPstackRole[input.role]
|
|
122
|
+
?? defaultSingleDelegationPanel;
|
|
123
|
+
const hasValidShape = panel
|
|
124
|
+
&& typeof panel === 'object'
|
|
125
|
+
&& Number.isInteger(panel.agentCount)
|
|
126
|
+
&& panel.agentCount > 0
|
|
127
|
+
&& typeof panel.requiresDistinctModels === 'boolean';
|
|
128
|
+
if (!hasValidShape) {
|
|
129
|
+
throw new Error('panel requires a positive agent count and a diversity boolean');
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
agentCount: panel.agentCount,
|
|
133
|
+
requiresDistinctModels: panel.requiresDistinctModels,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readHostPreferences(input) {
|
|
138
|
+
const preferencesDirectory = input.preferencesDirectory
|
|
139
|
+
?? resolve(dirname(fileURLToPath(import.meta.url)), '..', 'rules');
|
|
140
|
+
const preferencesPath = join(
|
|
141
|
+
preferencesDirectory,
|
|
142
|
+
'pstack-model-preferences.' + input.host + '.json',
|
|
143
|
+
);
|
|
144
|
+
if (!existsSync(preferencesPath)) {
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
const preferences = JSON.parse(readFileSync(preferencesPath, 'utf8'));
|
|
148
|
+
if (preferences.host !== input.host) {
|
|
149
|
+
throw new Error('preference host must match selected host');
|
|
150
|
+
}
|
|
151
|
+
return preferences.modelsByRole ?? {};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function collectCandidates(input, allPreferredModelIds) {
|
|
155
|
+
requireStringArray(allPreferredModelIds, 'role preferences');
|
|
156
|
+
const allAvailableModelIds = new Set(input.availableModelIds);
|
|
157
|
+
const allPreferredCandidates = allPreferredModelIds
|
|
158
|
+
.map(eachModelId => preferenceCandidate(eachModelId, allAvailableModelIds, input))
|
|
159
|
+
.filter(Boolean);
|
|
160
|
+
const allAlternativeCandidates = input.confirmedSuitableModelIds.map(
|
|
161
|
+
eachModelId => modelCandidate(eachModelId, 'confirmed-host-alternative'),
|
|
162
|
+
);
|
|
163
|
+
const allCandidates = deduplicateCandidates([
|
|
164
|
+
...allPreferredCandidates,
|
|
165
|
+
...allAlternativeCandidates,
|
|
166
|
+
]);
|
|
167
|
+
const allCandidatesWithFallback = allCandidates.length === 0
|
|
168
|
+
&& input.parentFallback.isAllowed
|
|
169
|
+
? [parentCandidate()]
|
|
170
|
+
: allCandidates;
|
|
171
|
+
return orderCrossJudgeCandidates(input, allCandidatesWithFallback);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function preferenceCandidate(modelId, allAvailableModelIds, input) {
|
|
175
|
+
if (inheritanceAliases.has(modelId)) {
|
|
176
|
+
return input.parentFallback.isAllowed ? parentCandidate() : null;
|
|
177
|
+
}
|
|
178
|
+
return allAvailableModelIds.has(modelId)
|
|
179
|
+
? modelCandidate(modelId, 'host-preference')
|
|
180
|
+
: null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function modelCandidate(modelId, source) {
|
|
184
|
+
return { kind: 'model', modelId, source };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parentCandidate() {
|
|
188
|
+
return { kind: 'parent', modelId: null, source: 'parent-inheritance' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function deduplicateCandidates(allCandidates) {
|
|
192
|
+
const seenCandidateKeys = new Set();
|
|
193
|
+
return allCandidates.filter(eachCandidate => {
|
|
194
|
+
const candidateKey = eachCandidate.kind + ':' + (eachCandidate.modelId ?? '');
|
|
195
|
+
if (seenCandidateKeys.has(candidateKey)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
seenCandidateKeys.add(candidateKey);
|
|
199
|
+
return true;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function orderCrossJudgeCandidates(input, allCandidates) {
|
|
204
|
+
if (input.role !== 'arena cross-judge pool' || !input.parentModelId) {
|
|
205
|
+
return allCandidates;
|
|
206
|
+
}
|
|
207
|
+
const allOtherCandidates = allCandidates.filter(
|
|
208
|
+
eachCandidate => eachCandidate.kind === 'model'
|
|
209
|
+
&& eachCandidate.modelId !== input.parentModelId,
|
|
210
|
+
);
|
|
211
|
+
const allParentCandidates = allCandidates.filter(
|
|
212
|
+
eachCandidate => eachCandidate.kind === 'parent'
|
|
213
|
+
|| eachCandidate.modelId === input.parentModelId,
|
|
214
|
+
);
|
|
215
|
+
return [...allOtherCandidates, ...allParentCandidates];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function selectPanelCandidates(allCandidates, panel) {
|
|
219
|
+
if (panel.requiresDistinctModels) {
|
|
220
|
+
return allCandidates
|
|
221
|
+
.filter(eachCandidate => eachCandidate.kind === 'model')
|
|
222
|
+
.slice(0, panel.agentCount);
|
|
223
|
+
}
|
|
224
|
+
if (allCandidates.length === 0) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
return Array.from(
|
|
228
|
+
{ length: panel.agentCount },
|
|
229
|
+
(_, index) => allCandidates[index % allCandidates.length],
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildSelection(input, requestedPanel, allSelectedCandidates) {
|
|
234
|
+
const panel = reportPanel(requestedPanel, allSelectedCandidates);
|
|
235
|
+
const failure = selectionFailure(input, panel, allSelectedCandidates);
|
|
236
|
+
const selectedCandidate = allSelectedCandidates[input.delegationIndex];
|
|
237
|
+
return {
|
|
238
|
+
selectedHost: input.host,
|
|
239
|
+
role: input.role,
|
|
240
|
+
requiredCapability: allPstackRoleRequirements[input.role],
|
|
241
|
+
availabilityValidated: true,
|
|
242
|
+
canDelegate: failure === null,
|
|
243
|
+
requiresUserChoice: failure !== null,
|
|
244
|
+
failure,
|
|
245
|
+
selectionSource: selectedCandidate?.source ?? null,
|
|
246
|
+
panel,
|
|
247
|
+
nativeSpawnArguments: nativeSpawnArguments(selectedCandidate, failure),
|
|
248
|
+
omitNativeModelArgument: selectedCandidate?.kind === 'parent',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function reportPanel(requestedPanel, allSelectedCandidates) {
|
|
253
|
+
const allSelectedModelIds = allSelectedCandidates.map(
|
|
254
|
+
eachCandidate => eachCandidate.modelId,
|
|
255
|
+
);
|
|
256
|
+
const distinctRealModelCount = new Set(allSelectedModelIds.filter(Boolean)).size;
|
|
257
|
+
return {
|
|
258
|
+
agentCount: requestedPanel.agentCount,
|
|
259
|
+
requiresDistinctModels: requestedPanel.requiresDistinctModels,
|
|
260
|
+
selectedModelIds: allSelectedModelIds,
|
|
261
|
+
selectionSources: allSelectedCandidates.map(eachCandidate => eachCandidate.source),
|
|
262
|
+
isCrossModelDiverse: requestedPanel.agentCount > 1
|
|
263
|
+
&& distinctRealModelCount === requestedPanel.agentCount,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function selectionFailure(input, panel, allSelectedCandidates) {
|
|
268
|
+
if (panel.requiresDistinctModels && !panel.isCrossModelDiverse) {
|
|
269
|
+
return 'distinct-models-unavailable';
|
|
270
|
+
}
|
|
271
|
+
if (allSelectedCandidates.length < panel.agentCount) {
|
|
272
|
+
return 'no-supported-model';
|
|
273
|
+
}
|
|
274
|
+
if (allSelectedCandidates.some(eachCandidate => eachCandidate.kind === 'parent')
|
|
275
|
+
&& input.parentFallback.hasMaterialCapabilityLoss) {
|
|
276
|
+
return 'parent-capability-loss';
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function nativeSpawnArguments(selectedCandidate, failure) {
|
|
282
|
+
if (failure || !selectedCandidate || selectedCandidate.kind === 'parent') {
|
|
283
|
+
return {};
|
|
284
|
+
}
|
|
285
|
+
return { model: selectedCandidate.modelId };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function readSelectionInput() {
|
|
289
|
+
let inputText = '';
|
|
290
|
+
for await (const eachChunk of process.stdin) {
|
|
291
|
+
inputText += eachChunk;
|
|
292
|
+
}
|
|
293
|
+
return JSON.parse(inputText);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isEntryPoint() {
|
|
297
|
+
return process.argv[1]
|
|
298
|
+
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (isEntryPoint()) {
|
|
302
|
+
const selectionInput = await readSelectionInput();
|
|
303
|
+
console.log(JSON.stringify(selectPstackDelegation(selectionInput)));
|
|
304
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { 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("renders deterministic private JSON", () => {
|
|
68
|
+
const report = auditHooks({ repositoryRoot });
|
|
69
|
+
const first = JSON.stringify(report);
|
|
70
|
+
const second = JSON.stringify(report);
|
|
71
|
+
assert.equal(first, second);
|
|
72
|
+
assert.equal(first.includes(repositoryRoot), false);
|
|
73
|
+
assert.equal(first.includes("python3 ${CLAUDE_PLUGIN_ROOT}"), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("reports every unclassified target", () => {
|
|
77
|
+
const temporaryRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-"));
|
|
78
|
+
const catalogPath = path.join(temporaryRoot, "lifecycle.json");
|
|
79
|
+
writeFileSync(catalogPath, '{"schema_version":1,"hooks":{}}', "utf8");
|
|
80
|
+
try {
|
|
81
|
+
const report = auditHooks({ catalogPath, repositoryRoot });
|
|
82
|
+
assert.equal(report.summary.unclassifiedCount, report.summary.uniqueTargetCount);
|
|
83
|
+
assert.equal(report.findings.some(({ code }) => code === "UNCLASSIFIED_HOOK"), true);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("keeps each hosted matcher as narrow as its roster", () => {
|
|
90
|
+
const fixture = createAuditFixture({
|
|
91
|
+
canonicalHooks: {
|
|
92
|
+
PreToolUse: [{
|
|
93
|
+
matcher: "Bash|Write",
|
|
94
|
+
hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py" }],
|
|
95
|
+
}],
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const constantsRoot = path.join(fixture.canonicalHooksRoot, "hooks_constants");
|
|
99
|
+
writeHookFile(fixture.canonicalHooksRoot, "blocking/pre_tool_use_dispatcher.py");
|
|
100
|
+
mkdirSync(constantsRoot, { recursive: true });
|
|
101
|
+
writeFileSync(
|
|
102
|
+
path.join(constantsRoot, "pre_tool_use_dispatcher_constants.py"),
|
|
103
|
+
[
|
|
104
|
+
'BASH_TOOL_NAME = "Bash"',
|
|
105
|
+
"ALL_BASH_TOOL_NAMES = frozenset({BASH_TOOL_NAME})",
|
|
106
|
+
"ALL_HOSTED_HOOK_ENTRIES = (",
|
|
107
|
+
'HostedHookEntry(script_relative_path="blocking/shell_substitution_blocker.py", applicable_tool_names=ALL_BASH_TOOL_NAMES),',
|
|
108
|
+
")",
|
|
109
|
+
].join("\n"),
|
|
110
|
+
"utf8",
|
|
111
|
+
);
|
|
112
|
+
try {
|
|
113
|
+
const report = auditHooks({ repositoryRoot: fixture.fixtureRoot });
|
|
114
|
+
const shellGate = report.hostedRegistrations.find(({ target }) =>
|
|
115
|
+
target.endsWith("shell_substitution_blocker.py"),
|
|
116
|
+
);
|
|
117
|
+
assert.equal(shellGate.matcher, "Bash");
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("omits untrusted lifecycle prose from output", () => {
|
|
124
|
+
const temporaryRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-"));
|
|
125
|
+
const catalogPath = path.join(temporaryRoot, "lifecycle.json");
|
|
126
|
+
const privateText = "PRIVATE_TOKEN python3 C:/Users/example/private.py";
|
|
127
|
+
const catalog = {
|
|
128
|
+
hooks: {
|
|
129
|
+
"script:blocking/code_rules_enforcer.py": {
|
|
130
|
+
lifecycle: "move_to_linter",
|
|
131
|
+
reason: privateText,
|
|
132
|
+
replacement: privateText,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
writeFileSync(catalogPath, JSON.stringify(catalog), "utf8");
|
|
137
|
+
try {
|
|
138
|
+
const rendered = JSON.stringify(auditHooks({ catalogPath, repositoryRoot }));
|
|
139
|
+
assert.equal(rendered.includes(privateText), false);
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("classifies installed-only targets", () => {
|
|
146
|
+
const fixture = createAuditFixture();
|
|
147
|
+
const temporaryRoot = fixture.fixtureRoot;
|
|
148
|
+
const claudeRoot = path.join(fixture.homeDirectory, ".claude");
|
|
149
|
+
const codexRoot = path.join(fixture.homeDirectory, ".codex");
|
|
150
|
+
const installedOnly = {
|
|
151
|
+
hooks: {
|
|
152
|
+
SessionStart: [{ hooks: [{ command: "node cleanup.mjs", type: "command" }], matcher: "" }],
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
writeJson(path.join(claudeRoot, "settings.json"), installedOnly);
|
|
156
|
+
writeJson(path.join(codexRoot, "hooks.json"), { hooks: {} });
|
|
157
|
+
const catalogPath = path.join(temporaryRoot, "lifecycle.json");
|
|
158
|
+
writeFileSync(catalogPath, '{"hooks":{}}', "utf8");
|
|
159
|
+
try {
|
|
160
|
+
const report = auditHooks({
|
|
161
|
+
catalogPath,
|
|
162
|
+
homeDirectory: fixture.homeDirectory,
|
|
163
|
+
includeInstalled: true,
|
|
164
|
+
repositoryRoot: temporaryRoot,
|
|
165
|
+
});
|
|
166
|
+
const missingTargets = new Set(
|
|
167
|
+
report.findings.filter(({ code }) => code === "UNCLASSIFIED_HOOK").map(({ target }) => target),
|
|
168
|
+
);
|
|
169
|
+
assert.equal(missingTargets.has("external:cleanup.mjs"), true);
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("reads dispatcher children from the installed hook tree", () => {
|
|
176
|
+
const fixture = createAuditFixture();
|
|
177
|
+
const temporaryRoot = fixture.fixtureRoot;
|
|
178
|
+
const claudeRoot = path.join(fixture.homeDirectory, ".claude");
|
|
179
|
+
const installedHooks = path.join(claudeRoot, "hooks");
|
|
180
|
+
const constantsRoot = path.join(installedHooks, "hooks_constants");
|
|
181
|
+
const blockingRoot = path.join(installedHooks, "blocking");
|
|
182
|
+
mkdirSync(constantsRoot, { recursive: true });
|
|
183
|
+
mkdirSync(blockingRoot, { recursive: true });
|
|
184
|
+
const config = {
|
|
185
|
+
hooks: {
|
|
186
|
+
PreToolUse: [{
|
|
187
|
+
hooks: [{
|
|
188
|
+
command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py",
|
|
189
|
+
type: "command",
|
|
190
|
+
}],
|
|
191
|
+
matcher: "Write",
|
|
192
|
+
}],
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const roster = [
|
|
196
|
+
'WRITE_TOOL_NAME = "Write"',
|
|
197
|
+
"ALL_WRITE_TOOL_NAMES = frozenset({WRITE_TOOL_NAME})",
|
|
198
|
+
"ALL_HOSTED_HOOK_ENTRIES = (",
|
|
199
|
+
'HostedHookEntry(script_relative_path="blocking/installed_only.py", applicable_tool_names=ALL_WRITE_TOOL_NAMES),',
|
|
200
|
+
")",
|
|
201
|
+
].join("\n");
|
|
202
|
+
writeJson(path.join(claudeRoot, "settings.json"), config);
|
|
203
|
+
writeFileSync(path.join(constantsRoot, "pre_tool_use_dispatcher_constants.py"), roster, "utf8");
|
|
204
|
+
writeFileSync(path.join(blockingRoot, "pre_tool_use_dispatcher.py"), "", "utf8");
|
|
205
|
+
writeJson(path.join(fixture.homeDirectory, ".codex", "hooks.json"), { hooks: {} });
|
|
206
|
+
try {
|
|
207
|
+
const report = auditHooks({
|
|
208
|
+
homeDirectory: fixture.homeDirectory,
|
|
209
|
+
includeInstalled: true,
|
|
210
|
+
repositoryRoot: temporaryRoot,
|
|
211
|
+
});
|
|
212
|
+
assert.equal(
|
|
213
|
+
report.installedLogicalRegistrations.some(({ target }) => target.endsWith("installed_only.py")),
|
|
214
|
+
true,
|
|
215
|
+
);
|
|
216
|
+
assert.equal(
|
|
217
|
+
report.findings.some(({ code, target }) =>
|
|
218
|
+
code === "MISSING_HOOK_TARGET" && target === "script:blocking/installed_only.py",
|
|
219
|
+
),
|
|
220
|
+
true,
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(temporaryRoot, { force: true, recursive: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("reports missing installed dispatcher targets", () => {
|
|
228
|
+
const fixture = createAuditFixture({
|
|
229
|
+
claudeHooks: {
|
|
230
|
+
hooks: {
|
|
231
|
+
PreToolUse: [{
|
|
232
|
+
matcher: "Write",
|
|
233
|
+
hooks: [{
|
|
234
|
+
type: "command",
|
|
235
|
+
command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py",
|
|
236
|
+
}],
|
|
237
|
+
}],
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const constantsPath = path.join(
|
|
242
|
+
fixture.homeDirectory,
|
|
243
|
+
".claude",
|
|
244
|
+
"hooks",
|
|
245
|
+
"hooks_constants",
|
|
246
|
+
"pre_tool_use_dispatcher_constants.py",
|
|
247
|
+
);
|
|
248
|
+
mkdirSync(path.dirname(constantsPath), { recursive: true });
|
|
249
|
+
writeFileSync(constantsPath, "ALL_HOSTED_HOOK_ENTRIES = ()\n", "utf8");
|
|
250
|
+
try {
|
|
251
|
+
const report = auditHooks({
|
|
252
|
+
homeDirectory: fixture.homeDirectory,
|
|
253
|
+
includeInstalled: true,
|
|
254
|
+
repositoryRoot: fixture.fixtureRoot,
|
|
255
|
+
});
|
|
256
|
+
assert.equal(
|
|
257
|
+
report.findings.some(({ code, scope, target }) =>
|
|
258
|
+
code === "MISSING_HOOK_TARGET" &&
|
|
259
|
+
scope === "claude" &&
|
|
260
|
+
target === "script:blocking/pre_tool_use_dispatcher.py",
|
|
261
|
+
),
|
|
262
|
+
true,
|
|
263
|
+
);
|
|
264
|
+
} finally {
|
|
265
|
+
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("checks canonical managed parity for Codex registrations", () => {
|
|
270
|
+
const fixture = createAuditFixture({
|
|
271
|
+
canonicalHooks: {
|
|
272
|
+
PreToolUse: [{
|
|
273
|
+
matcher: "Write",
|
|
274
|
+
hooks: [{ type: "command", command: "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py" }],
|
|
275
|
+
}],
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
try {
|
|
279
|
+
const report = auditHooks({
|
|
280
|
+
homeDirectory: fixture.homeDirectory,
|
|
281
|
+
includeInstalled: true,
|
|
282
|
+
repositoryRoot: fixture.fixtureRoot,
|
|
283
|
+
});
|
|
284
|
+
assert.equal(
|
|
285
|
+
report.findings.some(({ code, scope, target }) =>
|
|
286
|
+
code === "MISSING_MANAGED_REGISTRATION" &&
|
|
287
|
+
scope === "codex" &&
|
|
288
|
+
target === "script:blocking/write_existing_file_blocker.py",
|
|
289
|
+
),
|
|
290
|
+
true,
|
|
291
|
+
);
|
|
292
|
+
} finally {
|
|
293
|
+
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("compares managed registrations by event, matcher, and target", () => {
|
|
298
|
+
const command = "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py";
|
|
299
|
+
const fixture = createAuditFixture({
|
|
300
|
+
canonicalHooks: {
|
|
301
|
+
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command }] }],
|
|
302
|
+
},
|
|
303
|
+
claudeHooks: {
|
|
304
|
+
hooks: {
|
|
305
|
+
PostToolUse: [{ matcher: "Read", hooks: [{ type: "command", command }] }],
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
codexHooks: {
|
|
309
|
+
hooks: {
|
|
310
|
+
PreToolUse: [{ matcher: "Write", hooks: [{ type: "command", command }] }],
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
writeHookFile(path.join(fixture.homeDirectory, ".claude", "hooks"), "blocking/write_existing_file_blocker.py");
|
|
315
|
+
writeHookFile(path.join(fixture.homeDirectory, ".codex", "hooks"), "blocking/write_existing_file_blocker.py");
|
|
316
|
+
try {
|
|
317
|
+
const report = auditHooks({
|
|
318
|
+
homeDirectory: fixture.homeDirectory,
|
|
319
|
+
includeInstalled: true,
|
|
320
|
+
repositoryRoot: fixture.fixtureRoot,
|
|
321
|
+
});
|
|
322
|
+
assert.equal(
|
|
323
|
+
report.findings.some(({ code, scope, target }) =>
|
|
324
|
+
code === "MISSING_MANAGED_REGISTRATION" &&
|
|
325
|
+
scope === "claude" &&
|
|
326
|
+
target === "script:blocking/write_existing_file_blocker.py",
|
|
327
|
+
),
|
|
328
|
+
true,
|
|
329
|
+
);
|
|
330
|
+
assert.equal(
|
|
331
|
+
report.findings.some(({ code, scope }) =>
|
|
332
|
+
code === "MISSING_MANAGED_REGISTRATION" && scope === "codex",
|
|
333
|
+
),
|
|
334
|
+
false,
|
|
335
|
+
);
|
|
336
|
+
} finally {
|
|
337
|
+
rmSync(fixture.fixtureRoot, { force: true, recursive: true });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("finds default Git hooks from a worktree", () => {
|
|
342
|
+
const sourceRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-git-"));
|
|
343
|
+
const worktreeRoot = path.join(sourceRoot, "worktree");
|
|
344
|
+
const globalConfigPath = path.join(sourceRoot, "global-gitconfig");
|
|
345
|
+
writeFileSync(globalConfigPath, "", "utf8");
|
|
346
|
+
const environment = { ...process.env, GIT_CONFIG_GLOBAL: globalConfigPath, GIT_CONFIG_NOSYSTEM: "1" };
|
|
347
|
+
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
|
|
348
|
+
const previousNoSystem = process.env.GIT_CONFIG_NOSYSTEM;
|
|
349
|
+
process.env.GIT_CONFIG_GLOBAL = globalConfigPath;
|
|
350
|
+
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
|
351
|
+
try {
|
|
352
|
+
runGit(sourceRoot, ["init", "--quiet"], environment);
|
|
353
|
+
runGit(sourceRoot, ["config", "user.email", "hook-audit@example.invalid"], environment);
|
|
354
|
+
runGit(sourceRoot, ["config", "user.name", "Hook Audit"], environment);
|
|
355
|
+
runGit(sourceRoot, ["commit", "--quiet", "--allow-empty", "-m", "fixture"], environment);
|
|
356
|
+
runGit(sourceRoot, ["worktree", "add", "--quiet", worktreeRoot, "HEAD"], environment);
|
|
357
|
+
writeHookFile(path.join(sourceRoot, ".git", "hooks"), "pre-commit");
|
|
358
|
+
const canonicalHooksRoot = path.join(worktreeRoot, "packages", "claude-dev-env", "hooks");
|
|
359
|
+
const homeDirectory = path.join(sourceRoot, "home");
|
|
360
|
+
mkdirSync(canonicalHooksRoot, { recursive: true });
|
|
361
|
+
mkdirSync(path.join(homeDirectory, ".claude", "hooks"), { recursive: true });
|
|
362
|
+
mkdirSync(path.join(homeDirectory, ".codex", "hooks"), { recursive: true });
|
|
363
|
+
writeJson(path.join(canonicalHooksRoot, "hooks.json"), { hooks: {} });
|
|
364
|
+
writeJson(path.join(homeDirectory, ".claude", "settings.json"), { hooks: {} });
|
|
365
|
+
writeJson(path.join(homeDirectory, ".codex", "hooks.json"), { hooks: {} });
|
|
366
|
+
const report = auditHooks({
|
|
367
|
+
homeDirectory,
|
|
368
|
+
includeInstalled: true,
|
|
369
|
+
repositoryRoot: worktreeRoot,
|
|
370
|
+
});
|
|
371
|
+
assert.deepEqual(report.gitRegistrations.map(({ target }) => target), ["git:pre-commit"]);
|
|
372
|
+
} finally {
|
|
373
|
+
if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL;
|
|
374
|
+
else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
|
|
375
|
+
if (previousNoSystem === undefined) delete process.env.GIT_CONFIG_NOSYSTEM;
|
|
376
|
+
else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystem;
|
|
377
|
+
rmSync(sourceRoot, { force: true, recursive: true });
|
|
378
|
+
}
|
|
379
|
+
});
|