claude-dev-env 1.92.1 → 1.93.1
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/CLAUDE.md +6 -2
- package/_shared/CLAUDE.md +2 -1
- package/_shared/advisor/CLAUDE.md +21 -0
- package/_shared/advisor/advisor-protocol.md +142 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
- package/_shared/advisor/scripts/pyproject.toml +3 -0
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
- package/_shared/advisor/scripts/tier_model_ids.py +164 -0
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/precatch-rubric.md +65 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +260 -14
- package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
- package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
- package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
- package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +292 -2
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
- package/agents/CLAUDE.md +3 -2
- package/agents/clasp-deployment-orchestrator.md +2 -2
- package/agents/code-advisor.md +7 -5
- package/agents/code-verifier.md +6 -2
- package/agents/session-advisor.md +27 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
- package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
- package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
- package/bin/CLAUDE.md +1 -0
- package/bin/expand_home_directory_tokens.mjs +75 -0
- package/bin/install.mjs +21 -7
- package/bin/install.test.mjs +220 -10
- package/hooks/CLAUDE.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -1
- package/hooks/blocking/_path_setup.py +13 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
- package/hooks/blocking/code_rules_enforcer.py +6 -0
- package/hooks/blocking/code_rules_imports_logging.py +34 -1
- package/hooks/blocking/code_rules_paths_syspath.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +272 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
- package/hooks/blocking/pii_prevention_blocker.py +781 -0
- package/hooks/blocking/pii_scanner.py +365 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/stop_dispatcher.py +84 -0
- package/hooks/blocking/test__path_setup.py +39 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
- package/hooks/blocking/test_destructive_command_blocker.py +29 -9
- package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
- package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
- package/hooks/blocking/test_pii_scanner.py +190 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
- package/hooks/blocking/test_stop_dispatcher.py +187 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
- package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
- package/hooks/diagnostic/migrations/README.md +25 -26
- package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
- package/hooks/hooks.json +10 -120
- package/hooks/hooks_constants/CLAUDE.md +12 -3
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
- package/hooks/hooks_constants/local_identity.py +182 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
- package/hooks/hooks_constants/pii_prevention_constants.py +295 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
- package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
- package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
- package/hooks/hooks_constants/test_layout_constants.py +28 -0
- package/hooks/hooks_constants/test_local_identity.py +88 -0
- package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
- package/hooks/validators/README.md +5 -6
- package/hooks/validators/__init__.py +2 -2
- package/hooks/validators/python_style_checks.py +298 -243
- package/hooks/validators/run_all_validators.py +6 -0
- package/hooks/validators/test_python_style_checks.py +278 -163
- package/package.json +2 -2
- package/rules/CLAUDE.md +2 -1
- package/rules/bdd.md +1 -1
- package/rules/nas-ssh-invocation.md +6 -4
- package/rules/no-justification-noise.md +61 -0
- package/scripts/test_setup_project_paths.py +1 -1
- package/skills/CLAUDE.md +6 -3
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +113 -35
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +44 -22
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/reference/stop-conditions.md +23 -13
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
- package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +329 -68
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
- package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
- package/skills/bdd-protocol/SKILL.md +4 -5
- package/skills/bdd-protocol/references/anti-patterns.md +1 -1
- package/skills/bugteam/SKILL.md +8 -0
- package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
- package/skills/bugteam/reference/team-setup.md +7 -5
- package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
- package/skills/copilot-finding-triage/SKILL.md +124 -0
- package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
- package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
- package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
- package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
- package/skills/copilot-finding-triage/templates/notification.md +34 -0
- package/skills/copilot-review/SKILL.md +8 -6
- package/skills/findbugs/SKILL.md +4 -0
- package/skills/fixbugs/SKILL.md +8 -7
- package/skills/gotcha/CLAUDE.md +2 -2
- package/skills/gotcha/SKILL.md +4 -4
- package/skills/log-audit/SKILL.md +4 -6
- package/skills/monitor-open-prs/CLAUDE.md +1 -1
- package/skills/monitor-open-prs/SKILL.md +6 -1
- package/skills/orchestrator/SKILL.md +74 -78
- package/skills/orchestrator-refresh/SKILL.md +24 -12
- package/skills/post-audit-findings/SKILL.md +5 -9
- package/skills/pr-consistency-audit/SKILL.md +5 -1
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +86 -47
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +20 -17
- package/skills/pr-converge/reference/examples.md +63 -47
- package/skills/pr-converge/reference/fix-protocol.md +15 -15
- package/skills/pr-converge/reference/ground-rules.md +11 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
- package/skills/pr-converge/reference/per-tick.md +129 -107
- package/skills/pr-converge/reference/state-schema.md +15 -10
- package/skills/pr-converge/scripts/check_convergence.py +5 -4
- package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
- package/skills/pr-fix-protocol/SKILL.md +3 -8
- package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
- package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
- package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
- package/skills/pr-loop-lifecycle/SKILL.md +5 -9
- package/skills/pr-scope-resolve/SKILL.md +3 -6
- package/skills/privacy-hygiene/SKILL.md +114 -0
- package/skills/qbug/SKILL.md +8 -8
- package/skills/rebase/SKILL.md +5 -1
- package/skills/refine/SKILL.md +4 -5
- package/skills/reviewer-gates/SKILL.md +7 -11
- package/skills/session-log/SKILL.md +4 -1
- package/skills/skill-builder/SKILL.md +3 -6
- package/skills/structure-prompt/SKILL.md +4 -5
- package/skills/team-advisor/SKILL.md +56 -0
- package/skills/test_markdown_link_integrity.py +10 -6
- package/skills/update/SKILL.md +5 -1
- package/skills/usage-pause/SKILL.md +14 -5
- package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
- package/skills/verified-build/SKILL.md +4 -9
package/bin/install.test.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
9
9
|
import {
|
|
10
10
|
collectPackageSourceConflicts,
|
|
11
11
|
CONTENT_DIRECTORIES,
|
|
12
|
+
CORE_INCLUDE_DIRECTORIES,
|
|
12
13
|
FOLDED_HOOK_RELATIVE_PATHS,
|
|
13
14
|
POST_FOLDED_HOOK_RELATIVE_PATHS,
|
|
14
15
|
pythonCandidatesForPlatform,
|
|
@@ -21,6 +22,10 @@ import {
|
|
|
21
22
|
mergeHooksIntoSettings,
|
|
22
23
|
pruneManagedHooksFromSettings,
|
|
23
24
|
} from './install.mjs';
|
|
25
|
+
import {
|
|
26
|
+
expandHomeDirectoryTokens,
|
|
27
|
+
expandHomeDirectoryTokensInSettings,
|
|
28
|
+
} from './expand_home_directory_tokens.mjs';
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
function createTemporaryGitRepository() {
|
|
@@ -150,6 +155,18 @@ test('CONTENT_DIRECTORIES includes _shared so installer copies _shared/pr-loop/
|
|
|
150
155
|
});
|
|
151
156
|
|
|
152
157
|
|
|
158
|
+
test('core includeDirectories ships _shared and scripts for advisor protocol and CLI fallback', () => {
|
|
159
|
+
assert.ok(
|
|
160
|
+
CORE_INCLUDE_DIRECTORIES.includes('_shared'),
|
|
161
|
+
'_shared must ship with --only core so advisor-protocol.md lands for team-advisor/orchestrator',
|
|
162
|
+
);
|
|
163
|
+
assert.ok(
|
|
164
|
+
CORE_INCLUDE_DIRECTORIES.includes('scripts'),
|
|
165
|
+
'scripts must ship with --only core so claude_chain_runner.py is available for advisor CLI fallback',
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
|
|
153
170
|
test('CONTENT_DIRECTORIES includes audit-rubrics so installer copies category rubrics and prompts to ~/.claude/audit-rubrics/', () => {
|
|
154
171
|
assert.ok(
|
|
155
172
|
CONTENT_DIRECTORIES.includes('audit-rubrics'),
|
|
@@ -214,7 +231,7 @@ test('isWindowsStorePythonStub flags the Microsoft Store WindowsApps alias paths
|
|
|
214
231
|
true,
|
|
215
232
|
);
|
|
216
233
|
assert.equal(
|
|
217
|
-
isWindowsStorePythonStub('C:/Users/
|
|
234
|
+
isWindowsStorePythonStub('C:/Users/example/AppData/Local/Microsoft/WindowsApps/python.exe'),
|
|
218
235
|
true,
|
|
219
236
|
);
|
|
220
237
|
});
|
|
@@ -239,6 +256,199 @@ test('interpreterCommandFromPath quotes an interpreter path that contains a spac
|
|
|
239
256
|
});
|
|
240
257
|
|
|
241
258
|
|
|
259
|
+
test('expandHomeDirectoryTokens expands $HOME, ${HOME}, and ~/', () => {
|
|
260
|
+
assert.equal(
|
|
261
|
+
expandHomeDirectoryTokens(
|
|
262
|
+
'python $HOME/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
263
|
+
'C:\\Users\\x',
|
|
264
|
+
),
|
|
265
|
+
'python C:/Users/x/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
266
|
+
);
|
|
267
|
+
assert.equal(
|
|
268
|
+
expandHomeDirectoryTokens('python ${HOME}/.claude/hooks/a.py', '/home/x'),
|
|
269
|
+
'python /home/x/.claude/hooks/a.py',
|
|
270
|
+
);
|
|
271
|
+
assert.equal(
|
|
272
|
+
expandHomeDirectoryTokens('python ~/.claude/hooks/a.py', '/home/x'),
|
|
273
|
+
'python /home/x/.claude/hooks/a.py',
|
|
274
|
+
);
|
|
275
|
+
assert.equal(
|
|
276
|
+
expandHomeDirectoryTokens('echo $HOMEPATH', 'C:/Users/x'),
|
|
277
|
+
'echo $HOMEPATH',
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
test('expandHomeDirectoryTokens inserts dollar characters in home paths literally', () => {
|
|
283
|
+
const homeWithReplaceMetacharacters = 'C:/Users/$&evil$1';
|
|
284
|
+
assert.equal(
|
|
285
|
+
expandHomeDirectoryTokens('python $HOME/.claude/a.py', homeWithReplaceMetacharacters),
|
|
286
|
+
'python C:/Users/$&evil$1/.claude/a.py',
|
|
287
|
+
);
|
|
288
|
+
assert.equal(
|
|
289
|
+
expandHomeDirectoryTokens('python ${HOME}/.claude/a.py', homeWithReplaceMetacharacters),
|
|
290
|
+
'python C:/Users/$&evil$1/.claude/a.py',
|
|
291
|
+
);
|
|
292
|
+
assert.equal(
|
|
293
|
+
expandHomeDirectoryTokens('python ~/.claude/a.py', homeWithReplaceMetacharacters),
|
|
294
|
+
'python C:/Users/$&evil$1/.claude/a.py',
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
test('expandHomeDirectoryTokens expands ${HOME} before $HOME so braces stay intact', () => {
|
|
300
|
+
assert.equal(
|
|
301
|
+
expandHomeDirectoryTokens('python ${HOME}/.claude/a.py', '/home/x'),
|
|
302
|
+
'python /home/x/.claude/a.py',
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
test('expandHomeDirectoryTokens strips trailing slashes from the home directory', () => {
|
|
308
|
+
assert.equal(
|
|
309
|
+
expandHomeDirectoryTokens('python $HOME/.claude/a.py', 'C:/Users/x/'),
|
|
310
|
+
'python C:/Users/x/.claude/a.py',
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
test('expandHomeDirectoryTokensInSettings skips non-array hook event values', () => {
|
|
316
|
+
const settings = {
|
|
317
|
+
hooks: {
|
|
318
|
+
SessionStart: 'not-an-array',
|
|
319
|
+
PreToolUse: [
|
|
320
|
+
{
|
|
321
|
+
matcher: 'Write',
|
|
322
|
+
hooks: [{ type: 'command', command: 'python $HOME/.claude/hooks/a.py' }],
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
expandHomeDirectoryTokensInSettings(settings, 'C:/Users/x');
|
|
328
|
+
assert.equal(settings.hooks.SessionStart, 'not-an-array');
|
|
329
|
+
assert.equal(
|
|
330
|
+
settings.hooks.PreToolUse[0].hooks[0].command,
|
|
331
|
+
'python C:/Users/x/.claude/hooks/a.py',
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
test('expandHomeDirectoryTokensInSettings rewrites hooks and statusLine', () => {
|
|
337
|
+
const settings = {
|
|
338
|
+
hooks: {
|
|
339
|
+
SessionStart: [
|
|
340
|
+
{
|
|
341
|
+
matcher: '',
|
|
342
|
+
hooks: [
|
|
343
|
+
{
|
|
344
|
+
type: 'command',
|
|
345
|
+
command: 'python $HOME/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
statusLine: {
|
|
352
|
+
type: 'command',
|
|
353
|
+
command: 'python "$HOME/.claude/statusline-command.py"',
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
expandHomeDirectoryTokensInSettings(settings, 'C:/Users/x');
|
|
357
|
+
assert.equal(
|
|
358
|
+
settings.hooks.SessionStart[0].hooks[0].command,
|
|
359
|
+
'python C:/Users/x/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
360
|
+
);
|
|
361
|
+
assert.equal(
|
|
362
|
+
settings.statusLine.command,
|
|
363
|
+
'python "C:/Users/x/.claude/statusline-command.py"',
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
test('mergeHooksIntoSettings expands residual $HOME in preserved user hooks', () => {
|
|
369
|
+
const hooksConfig = {
|
|
370
|
+
hooks: {
|
|
371
|
+
SessionStart: [
|
|
372
|
+
{
|
|
373
|
+
matcher: '',
|
|
374
|
+
hooks: [
|
|
375
|
+
{
|
|
376
|
+
type: 'command',
|
|
377
|
+
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py',
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
const settings = {
|
|
385
|
+
hooks: {
|
|
386
|
+
SessionStart: [
|
|
387
|
+
{
|
|
388
|
+
matcher: '',
|
|
389
|
+
hooks: [
|
|
390
|
+
{
|
|
391
|
+
type: 'command',
|
|
392
|
+
command: 'python $HOME/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', 'C:/Python313/python.exe');
|
|
400
|
+
const allCommands = settings.hooks.SessionStart[0].hooks.map(eachHook => eachHook.command);
|
|
401
|
+
assert.ok(
|
|
402
|
+
allCommands.includes(
|
|
403
|
+
'python C:/Users/x/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
404
|
+
),
|
|
405
|
+
);
|
|
406
|
+
assert.ok(
|
|
407
|
+
allCommands.includes(
|
|
408
|
+
'C:/Python313/python.exe C:/Users/x/.claude/hooks/session/session_env_cleanup.py',
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
for (const eachCommand of allCommands) {
|
|
412
|
+
assert.equal(eachCommand.includes('$HOME'), false);
|
|
413
|
+
assert.equal(eachCommand.includes('${HOME}'), false);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
test('mergeHooksIntoSettings inserts dollar characters in plugin root and interpreter literally', () => {
|
|
419
|
+
const pluginRootWithReplaceMetacharacters = 'C:/Users/$&evil$1/.claude';
|
|
420
|
+
const pythonWithReplaceMetacharacters = 'C:/Users/$&evil$1/Python/python.exe';
|
|
421
|
+
const hooksConfig = {
|
|
422
|
+
hooks: {
|
|
423
|
+
SessionStart: [
|
|
424
|
+
{
|
|
425
|
+
matcher: '',
|
|
426
|
+
hooks: [
|
|
427
|
+
{
|
|
428
|
+
type: 'command',
|
|
429
|
+
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py',
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
const settings = {};
|
|
437
|
+
mergeHooksIntoSettings(
|
|
438
|
+
settings,
|
|
439
|
+
hooksConfig,
|
|
440
|
+
pluginRootWithReplaceMetacharacters,
|
|
441
|
+
pythonWithReplaceMetacharacters,
|
|
442
|
+
);
|
|
443
|
+
const rewrittenCommand = settings.hooks.SessionStart[0].hooks[0].command;
|
|
444
|
+
assert.equal(
|
|
445
|
+
rewrittenCommand,
|
|
446
|
+
'C:/Users/$&evil$1/Python/python.exe C:/Users/$&evil$1/.claude/hooks/session/session_env_cleanup.py',
|
|
447
|
+
);
|
|
448
|
+
assert.equal(rewrittenCommand.includes('${CLAUDE_PLUGIN_ROOT}'), false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
|
|
242
452
|
test('mergeHooksIntoSettings substitutes a quoted absolute interpreter path for the python3 prefix', () => {
|
|
243
453
|
const hooksConfig = {
|
|
244
454
|
hooks: {
|
|
@@ -374,14 +584,14 @@ test('commandReferencesManagedHook matches managed scripts written with $HOME, ~
|
|
|
374
584
|
assert.ok(commandReferencesManagedHook('python $HOME/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
375
585
|
assert.ok(commandReferencesManagedHook('python ~/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
376
586
|
assert.ok(commandReferencesManagedHook('python ${HOME}/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
377
|
-
assert.ok(commandReferencesManagedHook('py -3 C:/Users/
|
|
378
|
-
assert.ok(commandReferencesManagedHook('python /Users/
|
|
587
|
+
assert.ok(commandReferencesManagedHook('py -3 C:/Users/example/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
588
|
+
assert.ok(commandReferencesManagedHook('python /Users/example/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
|
|
379
589
|
});
|
|
380
590
|
|
|
381
591
|
|
|
382
592
|
test('commandReferencesManagedHook matches Windows backslash paths', () => {
|
|
383
593
|
const managedPaths = new Set(['blocking/hedging_language_blocker.py']);
|
|
384
|
-
assert.ok(commandReferencesManagedHook('py -3 C:\\Users\\
|
|
594
|
+
assert.ok(commandReferencesManagedHook('py -3 C:\\Users\\example\\.claude\\hooks\\blocking\\hedging_language_blocker.py', managedPaths));
|
|
385
595
|
});
|
|
386
596
|
|
|
387
597
|
|
|
@@ -414,7 +624,7 @@ test('commandReferencesManagedHook matches a managed script followed by a whites
|
|
|
414
624
|
test('commandReferencesManagedHook matches the rewritten inline validators-runner hook that carries no script tail', () => {
|
|
415
625
|
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
416
626
|
const rewrittenInlineCommand =
|
|
417
|
-
"py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/
|
|
627
|
+
"py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/example/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"";
|
|
418
628
|
assert.ok(commandReferencesManagedHook(rewrittenInlineCommand, managedPaths));
|
|
419
629
|
});
|
|
420
630
|
|
|
@@ -462,7 +672,7 @@ test('mergeHooksIntoSettings is idempotent for the inline -c validators hook acr
|
|
|
462
672
|
},
|
|
463
673
|
};
|
|
464
674
|
const settings = {};
|
|
465
|
-
const pluginRootDir = 'C:/Users/
|
|
675
|
+
const pluginRootDir = 'C:/Users/example/.claude';
|
|
466
676
|
|
|
467
677
|
mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, 'py -3');
|
|
468
678
|
mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, 'py -3');
|
|
@@ -499,8 +709,8 @@ test('mergeHooksIntoSettings preserves user hooks in a managed matcher group acr
|
|
|
499
709
|
},
|
|
500
710
|
};
|
|
501
711
|
|
|
502
|
-
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/
|
|
503
|
-
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/
|
|
712
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'py -3');
|
|
713
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'py -3');
|
|
504
714
|
|
|
505
715
|
const writeEditGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit');
|
|
506
716
|
const userHookSurvivors = writeEditGroup.hooks.filter(hook => hook.command === userHookCommand);
|
|
@@ -540,7 +750,7 @@ test('pruneManagedHooksFromSettings removes managed hooks in every home-path and
|
|
|
540
750
|
hooks: [
|
|
541
751
|
{ command: 'python $HOME/.claude/hooks/notification/attention_needed_notify.py', timeout: 15 },
|
|
542
752
|
{ command: 'python ${HOME}/.claude/hooks/notification/attention_needed_notify.py', timeout: 15 },
|
|
543
|
-
{ command: 'py -3 C:\\Users\\
|
|
753
|
+
{ command: 'py -3 C:\\Users\\example\\.claude\\hooks\\notification\\attention_needed_notify.py', timeout: 15 },
|
|
544
754
|
{ command: userHookCommand, timeout: 5 },
|
|
545
755
|
],
|
|
546
756
|
},
|
|
@@ -637,7 +847,7 @@ test('purge set sourced from package hooks.json prunes standalone managed script
|
|
|
637
847
|
{
|
|
638
848
|
matcher: 'Write|Edit',
|
|
639
849
|
hooks: [
|
|
640
|
-
{ command: 'py -3 C:\\Users\\
|
|
850
|
+
{ command: 'py -3 C:\\Users\\example\\.claude\\hooks\\blocking\\code_rules_enforcer.py', timeout: 30 },
|
|
641
851
|
{ command: userHookCommand, timeout: 5 },
|
|
642
852
|
],
|
|
643
853
|
},
|
package/hooks/CLAUDE.md
CHANGED
|
@@ -14,7 +14,7 @@ Python hook scripts wired into Claude Code's lifecycle via `settings.json`. Each
|
|
|
14
14
|
| `diagnostic/queries/` | Parameterized SQL queries for inspecting blocked commands |
|
|
15
15
|
| `git-hooks/` | Native git hooks (`pre-commit`, `pre-push`, `post-commit`) installed via the git-hooks path |
|
|
16
16
|
| `git-hooks/git_hooks_constants/` | Shared constants for the git-hook scripts |
|
|
17
|
-
| `hooks_constants/` | Shared constant modules imported by multiple hooks across this tree |
|
|
17
|
+
| `hooks_constants/` | Shared constant modules imported by multiple hooks across this tree (includes `pii_prevention_constants.py` for personal-data and secret scan patterns) |
|
|
18
18
|
| `lifecycle/` | Hooks that run at session or config-change boundaries |
|
|
19
19
|
| `observability/` | PostToolUse hooks that record agent behavior for diagnostics |
|
|
20
20
|
| `session/` | SessionStart and SessionEnd hooks for per-session cleanup |
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -50,6 +50,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
50
50
|
| `code_rules_shared.py` | Shared dataclasses and helpers used by multiple check modules |
|
|
51
51
|
| `code_rules_string_magic.py` | Magic string detection with masking and f-string support; whitespace-only indentation literals in function bodies |
|
|
52
52
|
| `code_rules_test_assertions.py` | Test assertion style rules |
|
|
53
|
+
| `code_rules_test_layout.py` | Dead scaffolding in a test module: a private module constant read by no other line, and an unused parameter on a private test helper |
|
|
53
54
|
| `code_rules_test_branching_except.py` | No bare or broad `except` in test branches |
|
|
54
55
|
| `code_rules_test_isolation.py` | Tests must not rely on home-dir or temp-dir side effects |
|
|
55
56
|
| `code_rules_type_escape.py` | No `Any` imports, `cast()`, or `# type: ignore` outside boundary files |
|
|
@@ -78,8 +79,10 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
78
79
|
| `hook_prose_detector_consistency.py` | PreToolUse (Write/Edit) | Hook docstrings/messages that claim a trigger the detector cannot fire on |
|
|
79
80
|
| `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
|
|
80
81
|
| `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
|
|
81
|
-
| `nas_ssh_binary_enforcer.py` | PreToolUse (Bash) | A bare `ssh`/`scp`/`sftp` command word targeting the NAS
|
|
82
|
+
| `nas_ssh_binary_enforcer.py` | PreToolUse (Bash) | A bare `ssh`/`scp`/`sftp` command word targeting the NAS (Git Bash's MSYS ssh stalls on an interactive password prompt), or the full `System32/OpenSSH` binary to that host without `-o BatchMode=yes` |
|
|
82
83
|
| `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
|
|
84
|
+
| `pii_prevention_blocker.py` | PreToolUse (Write/Edit/MultiEdit/Bash/PowerShell/MCP GitHub) | Content that carries high-confidence personal data or secrets (real emails, home-dir paths, private IPs, credential material) on write, durable GitHub posts, or staged commit paths |
|
|
85
|
+
| `pii_scanner.py` | library | Pure text scanners shared by `pii_prevention_blocker.py` |
|
|
83
86
|
| `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
|
|
84
87
|
| `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
|
|
85
88
|
| `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit`/`comment` bodies that fail the Anthropic claude-code style audit, proof-shaped `gh pr comment` bodies missing proof-of-work parts, and `gh pr ready` while the PR carries no passing proof comment |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Add the hooks directory to sys.path for sibling entry-point dispatchers.
|
|
2
|
+
|
|
3
|
+
Importing this module inserts the hooks directory (this file's parent's parent)
|
|
4
|
+
at the front of sys.path so a dispatcher that runs as a standalone script from
|
|
5
|
+
blocking/ can import hooks_constants with every import kept at module top.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if _hooks_directory not in sys.path:
|
|
13
|
+
sys.path.insert(0, _hooks_directory)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse dispatcher that hosts the Bash and PowerShell blocking hook chains.
|
|
3
|
+
|
|
4
|
+
Reads the tool payload from stdin once, selects the hosted hooks applicable to
|
|
5
|
+
the payload's tool name, runs each hook in-process via the shared hosted-hook
|
|
6
|
+
runner, aggregates deny/ask/allow decisions with deny>ask>allow precedence, and
|
|
7
|
+
emits one decision (carrying updatedInput when a rewriter allowed a rewrite, and
|
|
8
|
+
systemMessage / additionalContext / suppressOutput when hosted hooks set them).
|
|
9
|
+
|
|
10
|
+
A deny short-circuits the remaining chain: once any hosted hook denies, the
|
|
11
|
+
dispatcher aggregates what has run so far, emits that deny immediately, and
|
|
12
|
+
returns so a later process timeout cannot drop an already-known denial. Ask and
|
|
13
|
+
allow continue through the full roster because a later hook may still deny.
|
|
14
|
+
|
|
15
|
+
A single hosted hook crash fails open: it contributes no decision and does not
|
|
16
|
+
stop the remaining hooks, matching a standalone hook whose uncaught exception
|
|
17
|
+
exits nonzero without blocking the tool call.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import _path_setup # noqa: F401
|
|
28
|
+
|
|
29
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
30
|
+
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
31
|
+
ALLOW_DECISION,
|
|
32
|
+
ASK_DECISION,
|
|
33
|
+
CONTEXT_JOIN_SEPARATOR,
|
|
34
|
+
DENY_DECISION,
|
|
35
|
+
HOOK_EVENT_NAME,
|
|
36
|
+
REASON_JOIN_SEPARATOR,
|
|
37
|
+
BashHostedHookEntry,
|
|
38
|
+
)
|
|
39
|
+
from hooks_constants.hosted_hook_runner import HostedHookRun, run_hook_capturing_output
|
|
40
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
41
|
+
|
|
42
|
+
_PERMISSION_DECISION_KEY = "permissionDecision"
|
|
43
|
+
_PERMISSION_REASON_KEY = "permissionDecisionReason"
|
|
44
|
+
_HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
|
|
45
|
+
_UPDATED_INPUT_KEY = "updatedInput"
|
|
46
|
+
_SYSTEM_MESSAGE_KEY = "systemMessage"
|
|
47
|
+
_ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
48
|
+
_SUPPRESS_OUTPUT_KEY = "suppressOutput"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class BashDispatcherDecision:
|
|
53
|
+
"""Aggregated outcome across the Bash/PowerShell hosted-hook chain.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
decision: The winning permission outcome (deny, ask, allow), or empty
|
|
57
|
+
when no hosted hook emitted an outcome.
|
|
58
|
+
reasons: Deny or ask reasons collected from deciding hooks, in run order.
|
|
59
|
+
updated_input: The rewritten tool_input from an allowing rewriter, or
|
|
60
|
+
None when no allow carried an updatedInput.
|
|
61
|
+
all_system_messages: Top-level systemMessage texts from hosted hooks.
|
|
62
|
+
all_additional_context: hookSpecificOutput.additionalContext texts.
|
|
63
|
+
should_suppress_output: True when any hosted hook set suppressOutput.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
decision: str
|
|
67
|
+
reasons: list[str] = field(default_factory=list)
|
|
68
|
+
updated_input: dict[str, object] | None = None
|
|
69
|
+
all_system_messages: list[str] = field(default_factory=list)
|
|
70
|
+
all_additional_context: list[str] = field(default_factory=list)
|
|
71
|
+
should_suppress_output: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class _ParsedHookDecision:
|
|
76
|
+
"""Fields parsed from one hosted hook's stdout."""
|
|
77
|
+
|
|
78
|
+
decision: str
|
|
79
|
+
reason: str
|
|
80
|
+
updated_input: dict[str, object] | None
|
|
81
|
+
system_message: str
|
|
82
|
+
additional_context: str
|
|
83
|
+
should_suppress_output: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
|
|
87
|
+
"""Return the ordered hosted-hook entries that apply to tool_name."""
|
|
88
|
+
return [
|
|
89
|
+
each_entry
|
|
90
|
+
for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
|
|
91
|
+
if tool_name in each_entry.applicable_tool_names
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _empty_parsed_hook_decision() -> _ParsedHookDecision:
|
|
96
|
+
"""Return a non-deciding parse result with empty supplementary fields."""
|
|
97
|
+
return _ParsedHookDecision(
|
|
98
|
+
decision="",
|
|
99
|
+
reason="",
|
|
100
|
+
updated_input=None,
|
|
101
|
+
system_message="",
|
|
102
|
+
additional_context="",
|
|
103
|
+
should_suppress_output=False,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_hook_stdout(stdout_text: str) -> _ParsedHookDecision:
|
|
108
|
+
"""Parse one hook's stdout into a decision, reason, rewrite, and context."""
|
|
109
|
+
stripped_text = stdout_text.strip()
|
|
110
|
+
if not stripped_text:
|
|
111
|
+
return _empty_parsed_hook_decision()
|
|
112
|
+
try:
|
|
113
|
+
parsed_output = json.loads(stripped_text)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return _empty_parsed_hook_decision()
|
|
116
|
+
if not isinstance(parsed_output, dict):
|
|
117
|
+
return _empty_parsed_hook_decision()
|
|
118
|
+
hook_specific = parsed_output.get(_HOOK_SPECIFIC_OUTPUT_KEY, {})
|
|
119
|
+
if not isinstance(hook_specific, dict):
|
|
120
|
+
return _empty_parsed_hook_decision()
|
|
121
|
+
raw_decision = hook_specific.get(_PERMISSION_DECISION_KEY, "")
|
|
122
|
+
decision = raw_decision if isinstance(raw_decision, str) else ""
|
|
123
|
+
raw_reason = hook_specific.get(_PERMISSION_REASON_KEY, "")
|
|
124
|
+
reason = raw_reason if isinstance(raw_reason, str) else ""
|
|
125
|
+
raw_updated_input = hook_specific.get(_UPDATED_INPUT_KEY)
|
|
126
|
+
updated_input = raw_updated_input if isinstance(raw_updated_input, dict) else None
|
|
127
|
+
raw_system_message = parsed_output.get(_SYSTEM_MESSAGE_KEY, "")
|
|
128
|
+
system_message = raw_system_message if isinstance(raw_system_message, str) else ""
|
|
129
|
+
raw_additional_context = hook_specific.get(_ADDITIONAL_CONTEXT_KEY, "")
|
|
130
|
+
additional_context = (
|
|
131
|
+
raw_additional_context if isinstance(raw_additional_context, str) else ""
|
|
132
|
+
)
|
|
133
|
+
should_suppress_output = parsed_output.get(_SUPPRESS_OUTPUT_KEY) is True
|
|
134
|
+
return _ParsedHookDecision(
|
|
135
|
+
decision=decision,
|
|
136
|
+
reason=reason,
|
|
137
|
+
updated_input=updated_input,
|
|
138
|
+
system_message=system_message,
|
|
139
|
+
additional_context=additional_context,
|
|
140
|
+
should_suppress_output=should_suppress_output,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def aggregate_bash_hook_results(
|
|
145
|
+
all_runs: list[HostedHookRun],
|
|
146
|
+
) -> BashDispatcherDecision:
|
|
147
|
+
"""Aggregate hosted-hook runs into one deny>ask>allow decision.
|
|
148
|
+
|
|
149
|
+
Crashed hooks fail open: they contribute no decision. Reasons are collected
|
|
150
|
+
from deny and ask outputs. When the winning decision is allow, the first
|
|
151
|
+
non-empty updatedInput among allow results is carried through so rewriters
|
|
152
|
+
keep their contract. systemMessage, additionalContext, and suppressOutput
|
|
153
|
+
from every non-crashed hook are preserved so silent deny shapes (for example
|
|
154
|
+
destructive_command_blocker's gh-redirect deny) match standalone emission.
|
|
155
|
+
"""
|
|
156
|
+
all_parsed: list[_ParsedHookDecision] = []
|
|
157
|
+
for each_run in all_runs:
|
|
158
|
+
if each_run.did_crash:
|
|
159
|
+
continue
|
|
160
|
+
all_parsed.append(_parse_hook_stdout(each_run.captured_stdout))
|
|
161
|
+
|
|
162
|
+
decision_precedence = (DENY_DECISION, ASK_DECISION, ALLOW_DECISION)
|
|
163
|
+
winning_decision = ""
|
|
164
|
+
for each_candidate in decision_precedence:
|
|
165
|
+
if any(each_parsed.decision == each_candidate for each_parsed in all_parsed):
|
|
166
|
+
winning_decision = each_candidate
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
if not winning_decision:
|
|
170
|
+
return BashDispatcherDecision(decision="")
|
|
171
|
+
|
|
172
|
+
all_reasons = [
|
|
173
|
+
each_parsed.reason
|
|
174
|
+
for each_parsed in all_parsed
|
|
175
|
+
if each_parsed.decision in (DENY_DECISION, ASK_DECISION) and each_parsed.reason
|
|
176
|
+
]
|
|
177
|
+
updated_input: dict[str, object] | None = None
|
|
178
|
+
if winning_decision == ALLOW_DECISION:
|
|
179
|
+
for each_parsed in all_parsed:
|
|
180
|
+
if each_parsed.decision == ALLOW_DECISION and each_parsed.updated_input is not None:
|
|
181
|
+
updated_input = each_parsed.updated_input
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
all_system_messages = [
|
|
185
|
+
each_parsed.system_message for each_parsed in all_parsed if each_parsed.system_message
|
|
186
|
+
]
|
|
187
|
+
all_additional_context = [
|
|
188
|
+
each_parsed.additional_context
|
|
189
|
+
for each_parsed in all_parsed
|
|
190
|
+
if each_parsed.additional_context
|
|
191
|
+
]
|
|
192
|
+
should_suppress_output = any(
|
|
193
|
+
each_parsed.should_suppress_output for each_parsed in all_parsed
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return BashDispatcherDecision(
|
|
197
|
+
decision=winning_decision,
|
|
198
|
+
reasons=all_reasons,
|
|
199
|
+
updated_input=updated_input,
|
|
200
|
+
all_system_messages=all_system_messages,
|
|
201
|
+
all_additional_context=all_additional_context,
|
|
202
|
+
should_suppress_output=should_suppress_output,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
207
|
+
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
208
|
+
hooks_root = Path(__file__).resolve().parent.parent
|
|
209
|
+
return str(hooks_root / relative_path)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _emit_decision(decision: BashDispatcherDecision) -> None:
|
|
213
|
+
"""Write one PreToolUse permission payload to stdout when an outcome exists."""
|
|
214
|
+
if not decision.decision:
|
|
215
|
+
return
|
|
216
|
+
hook_specific: dict[str, object] = {
|
|
217
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
218
|
+
_PERMISSION_DECISION_KEY: decision.decision,
|
|
219
|
+
}
|
|
220
|
+
if decision.reasons:
|
|
221
|
+
hook_specific[_PERMISSION_REASON_KEY] = REASON_JOIN_SEPARATOR.join(decision.reasons)
|
|
222
|
+
if decision.decision == ALLOW_DECISION and decision.updated_input is not None:
|
|
223
|
+
hook_specific[_UPDATED_INPUT_KEY] = decision.updated_input
|
|
224
|
+
if decision.all_additional_context:
|
|
225
|
+
hook_specific[_ADDITIONAL_CONTEXT_KEY] = CONTEXT_JOIN_SEPARATOR.join(
|
|
226
|
+
decision.all_additional_context
|
|
227
|
+
)
|
|
228
|
+
payload: dict[str, object] = {_HOOK_SPECIFIC_OUTPUT_KEY: hook_specific}
|
|
229
|
+
if decision.all_system_messages:
|
|
230
|
+
payload[_SYSTEM_MESSAGE_KEY] = CONTEXT_JOIN_SEPARATOR.join(decision.all_system_messages)
|
|
231
|
+
if decision.should_suppress_output:
|
|
232
|
+
payload[_SUPPRESS_OUTPUT_KEY] = True
|
|
233
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
234
|
+
sys.stdout.flush()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _run_is_deny(hook_run: HostedHookRun) -> bool:
|
|
238
|
+
"""Return True when a non-crashed run carries a deny decision."""
|
|
239
|
+
if hook_run.did_crash:
|
|
240
|
+
return False
|
|
241
|
+
parsed_decision = _parse_hook_stdout(hook_run.captured_stdout)
|
|
242
|
+
return parsed_decision.decision == DENY_DECISION
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def dispatch(payload_text: str, tool_name: str) -> None:
|
|
246
|
+
"""Run applicable hosted hooks and emit the aggregated decision.
|
|
247
|
+
|
|
248
|
+
Emits and returns as soon as any hosted hook denies so a process timeout
|
|
249
|
+
later in the chain cannot fail-open past an already-known denial.
|
|
250
|
+
"""
|
|
251
|
+
applicable_entries = select_applicable_entries(tool_name)
|
|
252
|
+
all_runs: list[HostedHookRun] = []
|
|
253
|
+
for each_entry in applicable_entries:
|
|
254
|
+
script_path = _resolve_hook_script_path(each_entry.script_relative_path)
|
|
255
|
+
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
256
|
+
all_runs.append(hook_run)
|
|
257
|
+
if _run_is_deny(hook_run):
|
|
258
|
+
aggregated_decision = aggregate_bash_hook_results(all_runs)
|
|
259
|
+
_emit_decision(aggregated_decision)
|
|
260
|
+
return
|
|
261
|
+
aggregated_decision = aggregate_bash_hook_results(all_runs)
|
|
262
|
+
_emit_decision(aggregated_decision)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main() -> None:
|
|
266
|
+
"""Read stdin once and dispatch the Bash/PowerShell hosted-hook chain."""
|
|
267
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
268
|
+
if payload_dictionary is None:
|
|
269
|
+
sys.exit(0)
|
|
270
|
+
|
|
271
|
+
payload_text = json.dumps(payload_dictionary)
|
|
272
|
+
tool_name = payload_dictionary.get("tool_name", "")
|
|
273
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
274
|
+
sys.exit(0)
|
|
275
|
+
|
|
276
|
+
dispatch(payload_text, tool_name)
|
|
277
|
+
sys.exit(0)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
main()
|