claude-dev-env 1.17.1 → 1.17.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.mjs +145 -62
- package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +8 -6
- package/hooks/blocking/content-search-to-zoekt-redirector.py +55 -0
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +25 -0
- package/hooks/blocking/content_search_zoekt_block_payload.py +17 -0
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +24 -0
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +131 -0
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
- package/hooks/blocking/destructive-command-blocker.py +53 -4
- package/hooks/blocking/prompt_workflow_validate.py +218 -0
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +54 -0
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +51 -0
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +102 -0
- package/hooks/blocking/test_destructive_command_blocker.py +108 -0
- package/hooks/blocking/test_prompt_workflow_validate.py +339 -0
- package/hooks/hooks.json +0 -5
- package/package.json +4 -1
- package/skills/prompt-generator/ARCHITECTURE.md +2 -1
- package/skills/prompt-generator/REFERENCE.md +9 -11
- package/skills/prompt-generator/SKILL.md +41 -48
- package/skills/prompt-generator/TARGET_OUTPUT.md +25 -18
- package/skills/rule-audit/SKILL.md +2 -2
- package/hooks/blocking/prompt-workflow-stop-guard.py +0 -217
- package/hooks/blocking/test_prompt_workflow_stop_guard.py +0 -261
package/bin/install.mjs
CHANGED
|
@@ -5,14 +5,77 @@ import { join, dirname, resolve, relative } from 'node:path';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { execSync } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
8
9
|
|
|
9
10
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
10
11
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
12
|
const MANIFEST_FILE = join(CLAUDE_HOME, '.claude-dev-env-manifest.json');
|
|
12
13
|
const PACKAGE_NAME = 'claude-dev-env';
|
|
14
|
+
const packageRequire = createRequire(import.meta.url);
|
|
13
15
|
|
|
14
16
|
const CONTENT_DIRECTORIES = ['rules', 'docs', 'commands', 'agents'];
|
|
15
17
|
|
|
18
|
+
function resolveDependencyPackageRoot(dependencyPackageName) {
|
|
19
|
+
const dependencyPackageJsonPath = packageRequire.resolve(
|
|
20
|
+
`${dependencyPackageName}/package.json`
|
|
21
|
+
);
|
|
22
|
+
return dirname(dependencyPackageJsonPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function discoverDependencyGroups() {
|
|
26
|
+
const ownPackageJsonPath = join(PACKAGE_ROOT, 'package.json');
|
|
27
|
+
const ownPackageJson = JSON.parse(readFileSync(ownPackageJsonPath, 'utf8'));
|
|
28
|
+
const dependencies = ownPackageJson.dependencies || {};
|
|
29
|
+
const discoveredGroups = {};
|
|
30
|
+
for (const dependencyName of Object.keys(dependencies)) {
|
|
31
|
+
let dependencyRoot;
|
|
32
|
+
try {
|
|
33
|
+
dependencyRoot = resolveDependencyPackageRoot(dependencyName);
|
|
34
|
+
} catch {
|
|
35
|
+
console.error(` WARNING: Could not resolve dependency ${dependencyName}, skipping`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const dependencyPackageJson = JSON.parse(
|
|
39
|
+
readFileSync(join(dependencyRoot, 'package.json'), 'utf8')
|
|
40
|
+
);
|
|
41
|
+
const groupName = dependencyPackageJson.claudeDevEnv?.groupName
|
|
42
|
+
|| dependencyName.replace(/^@[^/]+\//, '');
|
|
43
|
+
const group = {
|
|
44
|
+
description: dependencyPackageJson.description || dependencyName,
|
|
45
|
+
packageRoot: dependencyRoot,
|
|
46
|
+
};
|
|
47
|
+
const skillsDirectory = join(dependencyRoot, 'skills');
|
|
48
|
+
if (existsSync(skillsDirectory)) {
|
|
49
|
+
group.skills = readdirSync(skillsDirectory, { withFileTypes: true })
|
|
50
|
+
.filter(entry => entry.isDirectory())
|
|
51
|
+
.map(entry => entry.name);
|
|
52
|
+
}
|
|
53
|
+
const hooksDirectory = join(dependencyRoot, 'hooks');
|
|
54
|
+
if (existsSync(hooksDirectory)) {
|
|
55
|
+
const hookFiles = collectFiles(hooksDirectory)
|
|
56
|
+
.filter(file => !file.endsWith('hooks.json'))
|
|
57
|
+
.filter(file => {
|
|
58
|
+
const baseName = file.replace(/\\/g, '/').split('/').pop();
|
|
59
|
+
return !baseName.startsWith('test_');
|
|
60
|
+
})
|
|
61
|
+
.map(file => relative(hooksDirectory, file).replace(/\\/g, '/'));
|
|
62
|
+
if (hookFiles.length > 0) {
|
|
63
|
+
group.includeHookFiles = hookFiles;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const rulesDirectory = join(dependencyRoot, 'rules');
|
|
67
|
+
if (existsSync(rulesDirectory)) {
|
|
68
|
+
const ruleFiles = readdirSync(rulesDirectory)
|
|
69
|
+
.filter(file => file.endsWith('.md'));
|
|
70
|
+
if (ruleFiles.length > 0) {
|
|
71
|
+
group.includeRules = ruleFiles;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
discoveredGroups[groupName] = group;
|
|
75
|
+
}
|
|
76
|
+
return discoveredGroups;
|
|
77
|
+
}
|
|
78
|
+
|
|
16
79
|
const INSTALL_GROUPS = {
|
|
17
80
|
core: {
|
|
18
81
|
description: 'Development standards, hooks, agents, commands',
|
|
@@ -25,17 +88,6 @@ const INSTALL_GROUPS = {
|
|
|
25
88
|
includeDirectories: ['rules', 'docs', 'commands', 'agents'],
|
|
26
89
|
includeAllHooks: true,
|
|
27
90
|
},
|
|
28
|
-
prompts: {
|
|
29
|
-
description: 'Prompt engineering tools',
|
|
30
|
-
skills: ['prompt-generator', 'agent-prompt'],
|
|
31
|
-
includeHookFiles: [
|
|
32
|
-
'blocking/prompt_workflow_gate_config.py',
|
|
33
|
-
'blocking/prompt_workflow_gate_core.py',
|
|
34
|
-
'blocking/prompt-workflow-stop-guard.py',
|
|
35
|
-
'HOOK_SPECS_PROMPT_WORKFLOW.md',
|
|
36
|
-
],
|
|
37
|
-
includeRules: ['prompt-workflow-context-controls.md'],
|
|
38
|
-
},
|
|
39
91
|
journal: {
|
|
40
92
|
description: 'Session logging and memory',
|
|
41
93
|
skills: ['dream', 'session-log', 'session-tidy'],
|
|
@@ -44,6 +96,7 @@ const INSTALL_GROUPS = {
|
|
|
44
96
|
description: 'Deep research and citation tools',
|
|
45
97
|
skills: ['deep-research', 'research-mode'],
|
|
46
98
|
},
|
|
99
|
+
...discoverDependencyGroups(),
|
|
47
100
|
};
|
|
48
101
|
|
|
49
102
|
function detectPython() {
|
|
@@ -99,8 +152,8 @@ function copyTree(sourceBase, destBase) {
|
|
|
99
152
|
return stats;
|
|
100
153
|
}
|
|
101
154
|
|
|
102
|
-
function mergeHooks(pythonCommand) {
|
|
103
|
-
const hooksJsonPath = join(
|
|
155
|
+
function mergeHooks(hooksSourceRoot, pythonCommand) {
|
|
156
|
+
const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
|
|
104
157
|
if (!existsSync(hooksJsonPath)) return 0;
|
|
105
158
|
const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
|
|
106
159
|
const settingsPath = join(CLAUDE_HOME, 'settings.json');
|
|
@@ -163,58 +216,85 @@ function install(selectedGroups) {
|
|
|
163
216
|
console.log(` Python: ${pythonCommand}`);
|
|
164
217
|
mkdirSync(CLAUDE_HOME, { recursive: true });
|
|
165
218
|
|
|
219
|
+
const activeGroups = selectedGroups
|
|
220
|
+
? selectedGroups.map(groupName => ({ groupName, ...INSTALL_GROUPS[groupName] }))
|
|
221
|
+
: Object.entries(INSTALL_GROUPS).map(([groupName, group]) => ({ groupName, ...group }));
|
|
222
|
+
|
|
166
223
|
const allowedSkills = selectedGroups
|
|
167
|
-
? new Set(
|
|
224
|
+
? new Set(activeGroups.flatMap(group => group.skills || []))
|
|
168
225
|
: null;
|
|
169
226
|
const allowedDirectories = selectedGroups
|
|
170
|
-
? new Set(
|
|
227
|
+
? new Set(activeGroups.flatMap(group => group.includeDirectories || []))
|
|
171
228
|
: null;
|
|
172
229
|
const shouldInstallAllHooks = selectedGroups
|
|
173
|
-
?
|
|
230
|
+
? activeGroups.some(group => group.includeAllHooks)
|
|
174
231
|
: true;
|
|
175
232
|
const allowedHookFiles = selectedGroups
|
|
176
|
-
? new Set(
|
|
233
|
+
? new Set(activeGroups.flatMap(group => group.includeHookFiles || []))
|
|
177
234
|
: null;
|
|
178
235
|
const allowedRules = selectedGroups
|
|
179
|
-
? new Set(
|
|
236
|
+
? new Set(activeGroups.flatMap(group => group.includeRules || []))
|
|
180
237
|
: null;
|
|
181
238
|
|
|
239
|
+
const dependencyRoots = [...new Set(
|
|
240
|
+
activeGroups.filter(group => group.packageRoot).map(group => group.packageRoot)
|
|
241
|
+
)];
|
|
242
|
+
const builtinGroupsActive = activeGroups.some(group => !group.packageRoot);
|
|
243
|
+
const allSourceRoots = [
|
|
244
|
+
...(builtinGroupsActive ? [PACKAGE_ROOT] : []),
|
|
245
|
+
...dependencyRoots,
|
|
246
|
+
];
|
|
247
|
+
|
|
182
248
|
const allInstalledFiles = [];
|
|
183
249
|
const summary = {};
|
|
184
250
|
for (const directory of CONTENT_DIRECTORIES) {
|
|
185
251
|
const hasFullAccess = !allowedDirectories || allowedDirectories.has(directory);
|
|
186
252
|
const hasPartialRules = directory === 'rules' && allowedRules && allowedRules.size > 0;
|
|
187
253
|
if (!hasFullAccess && !hasPartialRules) continue;
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
254
|
+
for (const sourceRoot of allSourceRoots) {
|
|
255
|
+
const sourceDir = join(sourceRoot, directory);
|
|
256
|
+
if (!existsSync(sourceDir)) continue;
|
|
257
|
+
const destDir = join(CLAUDE_HOME, directory);
|
|
258
|
+
if (hasFullAccess) {
|
|
259
|
+
const stats = copyTree(sourceDir, destDir);
|
|
260
|
+
if (!summary[directory]) {
|
|
261
|
+
summary[directory] = stats;
|
|
262
|
+
} else {
|
|
263
|
+
summary[directory].created += stats.created;
|
|
264
|
+
summary[directory].updated += stats.updated;
|
|
265
|
+
summary[directory].paths.push(...stats.paths);
|
|
266
|
+
}
|
|
267
|
+
allInstalledFiles.push(...stats.paths);
|
|
268
|
+
} else if (hasPartialRules) {
|
|
269
|
+
let rulesCreated = 0;
|
|
270
|
+
let rulesUpdated = 0;
|
|
271
|
+
for (const ruleFile of allowedRules) {
|
|
272
|
+
const sourcePath = join(sourceDir, ruleFile);
|
|
273
|
+
if (!existsSync(sourcePath)) continue;
|
|
274
|
+
const destPath = join(destDir, ruleFile);
|
|
275
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
276
|
+
const existed = existsSync(destPath);
|
|
277
|
+
copyFileSync(sourcePath, destPath);
|
|
278
|
+
allInstalledFiles.push(destPath);
|
|
279
|
+
if (existed) { rulesUpdated++; } else { rulesCreated++; }
|
|
280
|
+
console.log(` ${existed ? '\u21bb' : '\u2713'} ${join(directory, ruleFile)} (${existed ? 'updated' : 'new'})`);
|
|
281
|
+
}
|
|
282
|
+
if (!summary[directory]) {
|
|
283
|
+
summary[directory] = { created: rulesCreated, updated: rulesUpdated, paths: [] };
|
|
284
|
+
} else {
|
|
285
|
+
summary[directory].created += rulesCreated;
|
|
286
|
+
summary[directory].updated += rulesUpdated;
|
|
287
|
+
}
|
|
208
288
|
}
|
|
209
|
-
summary[directory] = { created: rulesCreated, updated: rulesUpdated, paths: [] };
|
|
210
289
|
}
|
|
211
290
|
}
|
|
212
|
-
|
|
213
|
-
|
|
291
|
+
let skillsCreated = 0;
|
|
292
|
+
let skillsUpdated = 0;
|
|
293
|
+
const skillPaths = [];
|
|
294
|
+
for (const sourceRoot of allSourceRoots) {
|
|
295
|
+
const skillsSource = join(sourceRoot, 'skills');
|
|
296
|
+
if (!existsSync(skillsSource)) continue;
|
|
214
297
|
const skillDirs = readdirSync(skillsSource, { withFileTypes: true }).filter(entry => entry.isDirectory());
|
|
215
|
-
let skillsCreated = 0;
|
|
216
|
-
let skillsUpdated = 0;
|
|
217
|
-
const skillPaths = [];
|
|
218
298
|
for (const skillDir of skillDirs) {
|
|
219
299
|
if (allowedSkills && !allowedSkills.has(skillDir.name)) continue;
|
|
220
300
|
const stats = copyTree(join(skillsSource, skillDir.name), join(CLAUDE_HOME, 'skills', skillDir.name));
|
|
@@ -222,13 +302,17 @@ function install(selectedGroups) {
|
|
|
222
302
|
skillsUpdated += stats.updated;
|
|
223
303
|
skillPaths.push(...stats.paths);
|
|
224
304
|
}
|
|
225
|
-
summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
|
|
226
|
-
allInstalledFiles.push(...skillPaths);
|
|
227
305
|
}
|
|
306
|
+
summary.skills = { created: skillsCreated, updated: skillsUpdated, paths: skillPaths };
|
|
307
|
+
allInstalledFiles.push(...skillPaths);
|
|
228
308
|
const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
|
|
229
309
|
if (shouldInstallAnyHooks) {
|
|
230
|
-
|
|
231
|
-
|
|
310
|
+
let totalHooksCreated = 0;
|
|
311
|
+
let totalHooksUpdated = 0;
|
|
312
|
+
let totalHookGroups = 0;
|
|
313
|
+
for (const sourceRoot of allSourceRoots) {
|
|
314
|
+
const hooksSource = join(sourceRoot, 'hooks');
|
|
315
|
+
if (!existsSync(hooksSource)) continue;
|
|
232
316
|
const hooksDestination = join(CLAUDE_HOME, 'hooks');
|
|
233
317
|
const filesToCopy = collectFiles(hooksSource)
|
|
234
318
|
.filter(file => !file.endsWith('hooks.json'))
|
|
@@ -237,8 +321,6 @@ function install(selectedGroups) {
|
|
|
237
321
|
const relativePath = relative(hooksSource, file).replace(/\\/g, '/');
|
|
238
322
|
return allowedHookFiles.has(relativePath);
|
|
239
323
|
});
|
|
240
|
-
let hooksCreated = 0;
|
|
241
|
-
let hooksUpdated = 0;
|
|
242
324
|
for (const sourceFile of filesToCopy) {
|
|
243
325
|
const relativePath = relative(hooksSource, sourceFile);
|
|
244
326
|
const destFile = join(hooksDestination, relativePath);
|
|
@@ -246,14 +328,15 @@ function install(selectedGroups) {
|
|
|
246
328
|
const existed = existsSync(destFile);
|
|
247
329
|
copyFileSync(sourceFile, destFile);
|
|
248
330
|
allInstalledFiles.push(destFile);
|
|
249
|
-
if (existed) {
|
|
331
|
+
if (existed) { totalHooksUpdated++; } else { totalHooksCreated++; }
|
|
250
332
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const groupCount = mergeHooks(pythonCommand);
|
|
254
|
-
summary.hookGroups = groupCount;
|
|
255
|
-
console.log(` Hook groups: ${groupCount} merged into settings.json`);
|
|
333
|
+
const groupCount = mergeHooks(sourceRoot, pythonCommand);
|
|
334
|
+
totalHookGroups += groupCount;
|
|
256
335
|
}
|
|
336
|
+
summary.hookFiles = { created: totalHooksCreated, updated: totalHooksUpdated };
|
|
337
|
+
console.log(` Hook files: ${totalHooksCreated} new, ${totalHooksUpdated} updated`);
|
|
338
|
+
summary.hookGroups = totalHookGroups;
|
|
339
|
+
console.log(` Hook groups: ${totalHookGroups} merged into settings.json`);
|
|
257
340
|
}
|
|
258
341
|
writeManifest(allInstalledFiles);
|
|
259
342
|
console.log(`\nInstalled ${PACKAGE_NAME}:`);
|
|
@@ -330,14 +413,14 @@ Usage:
|
|
|
330
413
|
npx ${PACKAGE_NAME} --help Show this help
|
|
331
414
|
|
|
332
415
|
Groups:
|
|
333
|
-
core
|
|
334
|
-
|
|
335
|
-
journal
|
|
336
|
-
research
|
|
416
|
+
core Development standards, hooks, agents, commands
|
|
417
|
+
prompt-generator Prompt engineering tools
|
|
418
|
+
journal Session logging and memory
|
|
419
|
+
research Deep research and citation tools
|
|
337
420
|
|
|
338
421
|
Examples:
|
|
339
|
-
npx ${PACKAGE_NAME} --only
|
|
340
|
-
npx ${PACKAGE_NAME} --only
|
|
422
|
+
npx ${PACKAGE_NAME} --only prompt-generator
|
|
423
|
+
npx ${PACKAGE_NAME} --only prompt-generator,research
|
|
341
424
|
|
|
342
425
|
Install location: ~/.claude/
|
|
343
426
|
`);
|
|
@@ -6,18 +6,20 @@ Deterministic runtime gates for prompt workflows.
|
|
|
6
6
|
|
|
7
7
|
The former `agent-execution-intent-gate.py` hook is **removed**. Native Agent/Task launches do not carry stable custom metadata; enforcing scope text on every spawn blocked legitimate `/agent-prompt` and refinement delegations. Scope and checklist rules remain enforced by the Stop guard when a prompt-workflow response is detected.
|
|
8
8
|
|
|
9
|
-
## Gate: Leakage + Checklist + Scope (
|
|
9
|
+
## Gate: Leakage + Checklist + Scope (file-based validation loop)
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- Fail
|
|
11
|
+
- Validator: `hooks/blocking/prompt_workflow_validate.py`
|
|
12
|
+
- Invocation: CLI against `data/prompts/.draft-prompt.xml` (exit 0 allowed, exit 2 blocked)
|
|
13
|
+
- Fail conditions:
|
|
14
14
|
- Raw internal refinement object appears in assistant output without explicit debug intent
|
|
15
15
|
- Prompt-workflow response detected but deterministic checklist container is missing
|
|
16
16
|
- Prompt-workflow response detected and required deterministic checklist rows are missing
|
|
17
17
|
- Prompt-workflow response detected and required scope anchors are missing
|
|
18
18
|
- Prompt-workflow response detected and runtime context-control signals are missing
|
|
19
19
|
- Scope-bound text uses banned ambiguous scope terms
|
|
20
|
-
-
|
|
20
|
+
- Banned negative keywords found inside fenced XML artifact
|
|
21
|
+
- Fenced XML artifact missing required sections
|
|
22
|
+
- Enforcement: The drafting subagent writes the draft file, runs the validator, reads stderr violations (each prefixed with `[reason_code]`), edits the file, and re-runs until exit 0.
|
|
21
23
|
|
|
22
24
|
## Required Scope Anchors
|
|
23
25
|
|
|
@@ -49,7 +51,7 @@ The former `agent-execution-intent-gate.py` hook is **removed**. Native Agent/Ta
|
|
|
49
51
|
- `base_minimal_instruction_layer: true`
|
|
50
52
|
- `on_demand_skill_loading: true`
|
|
51
53
|
|
|
52
|
-
These two signals are
|
|
54
|
+
These two signals are checked by the validator CLI whenever a prompt-workflow response is detected.
|
|
53
55
|
|
|
54
56
|
## Deterministic Boundary
|
|
55
57
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: deny Grep, Search, and shell search in indexed trees; steer to Zoekt MCP."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from content_search_zoekt_bash_block_reason import block_reason_for_bash_command
|
|
9
|
+
from content_search_zoekt_block_payload import build_block_payload
|
|
10
|
+
from content_search_zoekt_indexed_paths import is_in_indexed_repo, is_specific_file
|
|
11
|
+
from content_search_zoekt_redirect_guidance import get_zoekt_redirect_guidance
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
try:
|
|
16
|
+
input_data = json.load(sys.stdin)
|
|
17
|
+
except json.JSONDecodeError:
|
|
18
|
+
sys.exit(0)
|
|
19
|
+
|
|
20
|
+
tool_name = input_data.get("tool_name", "")
|
|
21
|
+
tool_input = input_data.get("tool_input", {})
|
|
22
|
+
|
|
23
|
+
content_search_tools = frozenset({"Grep", "Search"})
|
|
24
|
+
block_reason = None
|
|
25
|
+
|
|
26
|
+
if tool_name in content_search_tools:
|
|
27
|
+
pattern = tool_input.get("pattern", "")
|
|
28
|
+
path = tool_input.get("path", "")
|
|
29
|
+
|
|
30
|
+
if not path:
|
|
31
|
+
path = os.getcwd()
|
|
32
|
+
|
|
33
|
+
if is_specific_file(path):
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
|
|
36
|
+
if is_in_indexed_repo(path):
|
|
37
|
+
block_reason = f"{tool_name}(pattern: \"{pattern}\", path: \"{path}\")"
|
|
38
|
+
|
|
39
|
+
elif tool_name == "Bash":
|
|
40
|
+
command = tool_input.get("command", "")
|
|
41
|
+
block_reason = block_reason_for_bash_command(command)
|
|
42
|
+
|
|
43
|
+
if block_reason is None:
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
short_label = f"blocked {block_reason}; use Zoekt MCP"
|
|
46
|
+
payload = build_block_payload(
|
|
47
|
+
brief_label=short_label,
|
|
48
|
+
permission_decision_reason=get_zoekt_redirect_guidance(),
|
|
49
|
+
)
|
|
50
|
+
print(json.dumps(payload))
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Match Bash one-liners that act as content search (grep, rg, findstr, etc.)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def block_reason_for_bash_command(command: str) -> str | None:
|
|
7
|
+
bash_content_search_patterns = (
|
|
8
|
+
(re.compile(r"^\s*grep\s", re.IGNORECASE), "grep"),
|
|
9
|
+
(re.compile(r"^\s*grep$", re.IGNORECASE), "grep"),
|
|
10
|
+
(re.compile(r"\|\s*grep\s", re.IGNORECASE), "piped grep"),
|
|
11
|
+
(re.compile(r"\|\s*grep$", re.IGNORECASE), "piped grep"),
|
|
12
|
+
(re.compile(r"^\s*rg\s", re.IGNORECASE), "ripgrep"),
|
|
13
|
+
(re.compile(r"^\s*rg$", re.IGNORECASE), "ripgrep"),
|
|
14
|
+
(re.compile(r"\|\s*rg\s", re.IGNORECASE), "piped ripgrep"),
|
|
15
|
+
(re.compile(r"^\s*findstr\s", re.IGNORECASE), "findstr"),
|
|
16
|
+
(re.compile(r"^\s*Select-String", re.IGNORECASE), "PowerShell Select-String"),
|
|
17
|
+
(re.compile(r"^\s*sls\s", re.IGNORECASE), "PowerShell sls"),
|
|
18
|
+
(re.compile(r"^\s*ack\s", re.IGNORECASE), "ack"),
|
|
19
|
+
(re.compile(r"^\s*ag\s", re.IGNORECASE), "silver searcher"),
|
|
20
|
+
(re.compile(r"^\s*git\s+grep\s", re.IGNORECASE), "git grep"),
|
|
21
|
+
)
|
|
22
|
+
for regex, command_name in bash_content_search_patterns:
|
|
23
|
+
if regex.search(command):
|
|
24
|
+
return f"Bash({command_name})"
|
|
25
|
+
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""JSON shape for Claude Code PreToolUse deny: hookSpecificOutput plus short systemMessage."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def build_block_payload(
|
|
5
|
+
brief_label: str,
|
|
6
|
+
permission_decision_reason: str,
|
|
7
|
+
) -> dict:
|
|
8
|
+
destructive_gate_label_prefix = "[destructive-gate]"
|
|
9
|
+
return {
|
|
10
|
+
"hookSpecificOutput": {
|
|
11
|
+
"hookEventName": "PreToolUse",
|
|
12
|
+
"permissionDecision": "deny",
|
|
13
|
+
"permissionDecisionReason": permission_decision_reason,
|
|
14
|
+
},
|
|
15
|
+
"systemMessage": f"{destructive_gate_label_prefix} {brief_label}",
|
|
16
|
+
"suppressOutput": True,
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Normalize paths and test membership under Zoekt-indexed roots (from env, JSON file, or defaults)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_path(path: str) -> str:
|
|
7
|
+
return path.replace("\\", "/").lower()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_specific_file(path: str) -> bool:
|
|
11
|
+
file_extension_pattern = re.compile(r"\.\w{1,10}$")
|
|
12
|
+
return bool(file_extension_pattern.search(path))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_in_indexed_repo(path: str) -> bool:
|
|
16
|
+
from content_search_zoekt_indexed_roots_config import indexed_root_prefixes
|
|
17
|
+
|
|
18
|
+
norm = normalize_path(path)
|
|
19
|
+
if not norm.endswith("/"):
|
|
20
|
+
norm += "/"
|
|
21
|
+
for prefix in indexed_root_prefixes():
|
|
22
|
+
if norm.startswith(prefix):
|
|
23
|
+
return True
|
|
24
|
+
return False
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Resolve Zoekt-indexed filesystem roots from environment or JSON file (no built-in roots in this package)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from content_search_zoekt_indexed_paths import normalize_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _environment_variable_indexed_roots() -> str:
|
|
12
|
+
return "ZOEKT_REDIRECT_INDEXED_ROOTS"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _environment_variable_roots_file() -> str:
|
|
16
|
+
return "ZOEKT_REDIRECT_INDEXED_ROOTS_FILE"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _json_object_roots_key() -> str:
|
|
20
|
+
return "roots"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_config_relative_parts() -> tuple[str, str]:
|
|
24
|
+
return (".claude", "zoekt-indexed-roots.json")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _built_in_fallback_roots() -> tuple[str, ...]:
|
|
28
|
+
return ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_json_roots_list(raw: str) -> list[str] | None:
|
|
32
|
+
try:
|
|
33
|
+
parsed = json.loads(raw)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
return None
|
|
36
|
+
if not isinstance(parsed, list):
|
|
37
|
+
return None
|
|
38
|
+
if not all(isinstance(item, str) for item in parsed):
|
|
39
|
+
return None
|
|
40
|
+
return list(parsed)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _config_file_path() -> Path:
|
|
44
|
+
override = os.environ.get(_environment_variable_roots_file())
|
|
45
|
+
if override is not None and override.strip() != "":
|
|
46
|
+
return Path(override).expanduser()
|
|
47
|
+
relative_dot_claude, relative_file_name = _default_config_relative_parts()
|
|
48
|
+
return Path.home() / relative_dot_claude / relative_file_name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _roots_from_json_file() -> list[str] | None:
|
|
52
|
+
path = _config_file_path()
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
text = path.read_text(encoding="utf-8")
|
|
57
|
+
except OSError:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(text)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return None
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
return None
|
|
65
|
+
roots_key = _json_object_roots_key()
|
|
66
|
+
roots_value = data.get(roots_key)
|
|
67
|
+
if roots_value is None:
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(roots_value, list):
|
|
70
|
+
return None
|
|
71
|
+
if not all(isinstance(item, str) for item in roots_value):
|
|
72
|
+
return None
|
|
73
|
+
return list(roots_value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _roots_from_environment_variable() -> list[str] | None:
|
|
77
|
+
variable_name = _environment_variable_indexed_roots()
|
|
78
|
+
if variable_name not in os.environ:
|
|
79
|
+
return None
|
|
80
|
+
raw = os.environ.get(variable_name, "")
|
|
81
|
+
if raw.strip() == "":
|
|
82
|
+
return []
|
|
83
|
+
parsed = _parse_json_roots_list(raw)
|
|
84
|
+
if parsed is None:
|
|
85
|
+
return None
|
|
86
|
+
return parsed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _expand_root_to_prefix_variants(root: str) -> list[str]:
|
|
90
|
+
trimmed = root.strip()
|
|
91
|
+
if trimmed == "":
|
|
92
|
+
return []
|
|
93
|
+
norm = normalize_path(trimmed)
|
|
94
|
+
if not norm.endswith("/"):
|
|
95
|
+
norm = norm + "/"
|
|
96
|
+
variants = [norm]
|
|
97
|
+
if len(norm) >= 3 and norm[1] == ":" and norm[0].isalpha() and norm[2] == "/":
|
|
98
|
+
drive_letter = norm[0]
|
|
99
|
+
remainder = norm[2:]
|
|
100
|
+
wsl_prefix = f"/mnt/{drive_letter}{remainder}"
|
|
101
|
+
if wsl_prefix not in variants:
|
|
102
|
+
variants.append(wsl_prefix)
|
|
103
|
+
return variants
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _expand_all_roots(raw_roots: list[str]) -> tuple[str, ...]:
|
|
107
|
+
prefixes: list[str] = []
|
|
108
|
+
for root in raw_roots:
|
|
109
|
+
prefixes.extend(_expand_root_to_prefix_variants(root))
|
|
110
|
+
unique = frozenset(prefixes)
|
|
111
|
+
return tuple(sorted(unique, key=len, reverse=True))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _raw_roots_resolution_order() -> list[str]:
|
|
115
|
+
from_env = _roots_from_environment_variable()
|
|
116
|
+
if from_env is not None:
|
|
117
|
+
return from_env
|
|
118
|
+
from_file = _roots_from_json_file()
|
|
119
|
+
if from_file is not None:
|
|
120
|
+
return from_file
|
|
121
|
+
return list(_built_in_fallback_roots())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@lru_cache(maxsize=1)
|
|
125
|
+
def indexed_root_prefixes() -> tuple[str, ...]:
|
|
126
|
+
raw_roots = _raw_roots_resolution_order()
|
|
127
|
+
return _expand_all_roots(raw_roots)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def clear_indexed_root_prefixes_cache() -> None:
|
|
131
|
+
indexed_root_prefixes.cache_clear()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Zoekt MCP usage and repo-to-disk path mapping for PreToolUse permissionDecisionReason."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_zoekt_redirect_guidance() -> str:
|
|
5
|
+
return (
|
|
6
|
+
"Use Zoekt MCP instead: mcp__zoekt__search(query=\"your pattern\"). "
|
|
7
|
+
"Supports regex, 'file:pattern' for file filtering, 'lang:py' for language. "
|
|
8
|
+
"Also available: mcp__zoekt__search_symbols, mcp__zoekt__find_references, mcp__zoekt__file_content. "
|
|
9
|
+
"Example: mcp__zoekt__search(query=\"verify_theme_assets file:\\.py$\")\n\n"
|
|
10
|
+
"INDEX ROOTS (when Grep/Search in a tree is redirected): set ZOEKT_REDIRECT_INDEXED_ROOTS to a JSON array "
|
|
11
|
+
"of absolute paths, or ~/.claude/zoekt-indexed-roots.json as {\"roots\": [\"/abs/path/to/repo/\", ...]}. "
|
|
12
|
+
"Optional ZOEKT_REDIRECT_INDEXED_ROOTS_FILE points to a different JSON file. "
|
|
13
|
+
"WSL /mnt/<drive>/... prefixes are derived from Windows roots automatically. "
|
|
14
|
+
"This package ships no built-in roots (public repo); you must configure roots locally.\n\n"
|
|
15
|
+
"ZOEKT REPO LABEL -> LOCAL DISK (for editing files after a Zoekt hit): "
|
|
16
|
+
"keep the same directories in zoekt-indexed-roots.json as you index in Zoekt. "
|
|
17
|
+
"Example pattern only — yours will differ: if Zoekt shows \"acme-lib - src/foo.py\" and that repo "
|
|
18
|
+
"lives at /srv/checkout/acme-lib/ on your machine, edit /srv/checkout/acme-lib/src/foo.py."
|
|
19
|
+
)
|