claude-dev-env 4.1.0 → 4.3.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/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
- package/.agents/skills/everything-search/SKILL.md +18 -3
- package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
- package/.agents/skills/issue-tracker/SKILL.md +6 -0
- package/.agents/skills/pull-request/SKILL.md +218 -0
- package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
- package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
- package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
- package/.agents/skills/test-runner/SKILL.md +69 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
- package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/install.mjs +9 -1
- package/bin/install.prune.test.mjs +149 -0
- package/bin/install.test.mjs +24 -7
- package/hooks/blocking/pii_prevention_blocker.py +32 -51
- package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
- package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
- package/hooks/gh_post_body_texts.py +195 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
- package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
- package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
- package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
- package/hooks/observability/test_failure_recorder.py +1 -1
- package/hooks/pyproject.toml +1 -1
- package/hooks/test_gh_post_body_texts.py +79 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +31 -20
- package/rules/filesystem-search.md +5 -5
- package/rules/gh-cli-conventions.md +23 -13
- package/rules/git-workflow.md +21 -8
- package/scripts/policy_lint/adapter_configuration.py +53 -12
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/tests/test_adapter_configuration.py +54 -0
- package/hooks/_gh_pr_author_swap_utils.py +0 -1225
- package/hooks/blocking/conventional_pr_title_gate.py +0 -443
- package/hooks/blocking/es_exe_path_rewriter.py +0 -159
- package/hooks/blocking/gh_body_arg_blocker.py +0 -176
- package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
- package/hooks/blocking/gh_pr_author_restore.py +0 -105
- package/hooks/blocking/pr_description_writer_gate.py +0 -256
- package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
- package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
- package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
- package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
- package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
- package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
- package/hooks/blocking/test_preflight_check.py +0 -343
- package/hooks/blocking/test_test_preflight_check.py +0 -80
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
- package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
- package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
- package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
- package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
- package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
- package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
- package/hooks/test__gh_pr_author_swap_utils.py +0 -333
|
@@ -66,6 +66,41 @@ const NESTED_SKILL_DIRECTORY = 'foo';
|
|
|
66
66
|
const NESTED_SKILL_FILE_SEGMENTS = [NESTED_SKILL_DIRECTORY, 'scripts', 'a.py'];
|
|
67
67
|
const MYPY_INI_FILE_NAME = '.mypy.ini';
|
|
68
68
|
const SKIPPED_RECORD_SUMMARY_MARKER = 'manifest record(s) skipped';
|
|
69
|
+
const RETIRED_PULL_REQUEST_HOOK_RELATIVE_PATHS = [
|
|
70
|
+
'_gh_pr_author_swap_utils.py',
|
|
71
|
+
'test__gh_pr_author_swap_utils.py',
|
|
72
|
+
'blocking/conventional_pr_title_gate.py',
|
|
73
|
+
'blocking/gh_body_arg_blocker.py',
|
|
74
|
+
'blocking/gh_pr_author_enforcer.py',
|
|
75
|
+
'blocking/gh_pr_author_restore.py',
|
|
76
|
+
'blocking/pr_description_writer_gate.py',
|
|
77
|
+
'blocking/test_conventional_pr_title_gate.py',
|
|
78
|
+
'blocking/test_gh_body_arg_blocker.py',
|
|
79
|
+
'blocking/test_gh_pr_author_enforcer.py',
|
|
80
|
+
'blocking/test_gh_pr_author_restore.py',
|
|
81
|
+
'blocking/test_gh_pr_author_swap_utils.py',
|
|
82
|
+
'blocking/test_pr_description_writer_gate.py',
|
|
83
|
+
'blocking/test_volatile_path_in_post_blocker.py',
|
|
84
|
+
'blocking/volatile_path_in_post_blocker.py',
|
|
85
|
+
'hooks_constants/conventional_pr_title_gate_constants.py',
|
|
86
|
+
'hooks_constants/gh_pr_author_swap_constants.py',
|
|
87
|
+
'hooks_constants/pr_description_writer_gate_constants.py',
|
|
88
|
+
'hooks_constants/volatile_path_in_post_blocker_constants.py',
|
|
89
|
+
'observability/pr_description_writer_spawn_tracker.py',
|
|
90
|
+
'observability/test_pr_description_writer_spawn_tracker.py',
|
|
91
|
+
'session/gh_pr_author_session_cleanup.py',
|
|
92
|
+
'session/test_gh_pr_author_session_cleanup.py',
|
|
93
|
+
];
|
|
94
|
+
const RETIRED_PULL_REQUEST_REGISTRATION_PATHS = [
|
|
95
|
+
'blocking/conventional_pr_title_gate.py',
|
|
96
|
+
'blocking/gh_body_arg_blocker.py',
|
|
97
|
+
'blocking/gh_pr_author_enforcer.py',
|
|
98
|
+
'blocking/gh_pr_author_restore.py',
|
|
99
|
+
'blocking/pr_description_writer_gate.py',
|
|
100
|
+
'blocking/volatile_path_in_post_blocker.py',
|
|
101
|
+
'observability/pr_description_writer_spawn_tracker.py',
|
|
102
|
+
'session/gh_pr_author_session_cleanup.py',
|
|
103
|
+
];
|
|
69
104
|
|
|
70
105
|
/**
|
|
71
106
|
* Report whether a path under one managed root landed in a run backup.
|
|
@@ -1054,6 +1089,120 @@ test('a full reinstall drops a retired hook script and its settings entry in one
|
|
|
1054
1089
|
}
|
|
1055
1090
|
});
|
|
1056
1091
|
|
|
1092
|
+
test('a full reinstall removes the retired Everything path rewriter and keeps a foreign hook neighbor', () => {
|
|
1093
|
+
const sandbox = createSandbox();
|
|
1094
|
+
try {
|
|
1095
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
1096
|
+
const retiredHookPath = join(
|
|
1097
|
+
sandbox.claudeDirectory,
|
|
1098
|
+
HOOKS_DIRECTORY_NAME,
|
|
1099
|
+
'blocking',
|
|
1100
|
+
'es_exe_path_rewriter.py',
|
|
1101
|
+
);
|
|
1102
|
+
writeFileWithParents(retiredHookPath, 'a hook an earlier install wrote\n');
|
|
1103
|
+
appendManifestFiles(sandbox.manifestPath, [retiredHookPath]);
|
|
1104
|
+
|
|
1105
|
+
const foreignHookPath = join(
|
|
1106
|
+
sandbox.claudeDirectory,
|
|
1107
|
+
HOOKS_DIRECTORY_NAME,
|
|
1108
|
+
'blocking',
|
|
1109
|
+
'foreign_search_hook.py',
|
|
1110
|
+
);
|
|
1111
|
+
const foreignHookBytes = Buffer.from('a user hook the installer never wrote\n');
|
|
1112
|
+
writeFileWithParents(foreignHookPath, foreignHookBytes);
|
|
1113
|
+
|
|
1114
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
1115
|
+
|
|
1116
|
+
const freshManifest = readManifest(sandbox.manifestPath);
|
|
1117
|
+
const installedDispatcherConstantsPath = join(
|
|
1118
|
+
sandbox.claudeDirectory,
|
|
1119
|
+
HOOKS_DIRECTORY_NAME,
|
|
1120
|
+
'hooks_constants',
|
|
1121
|
+
'bash_pre_tool_use_dispatcher_constants.py',
|
|
1122
|
+
);
|
|
1123
|
+
assert.equal(existsSync(retiredHookPath), false, 'the retired search hook leaves the installed hooks');
|
|
1124
|
+
assert.equal(
|
|
1125
|
+
freshManifest.files.includes(retiredHookPath),
|
|
1126
|
+
false,
|
|
1127
|
+
'the fresh manifest drops the retired search hook',
|
|
1128
|
+
);
|
|
1129
|
+
assert.equal(
|
|
1130
|
+
freshManifest.files.includes(foreignHookPath),
|
|
1131
|
+
false,
|
|
1132
|
+
'the fresh manifest does not claim the foreign hook',
|
|
1133
|
+
);
|
|
1134
|
+
assert.deepEqual(
|
|
1135
|
+
readFileSync(foreignHookPath),
|
|
1136
|
+
foreignHookBytes,
|
|
1137
|
+
'the foreign hook keeps its original bytes',
|
|
1138
|
+
);
|
|
1139
|
+
assert.equal(
|
|
1140
|
+
readFileSync(installedDispatcherConstantsPath, 'utf8').includes(
|
|
1141
|
+
'es_exe_path_rewriter.py',
|
|
1142
|
+
),
|
|
1143
|
+
false,
|
|
1144
|
+
'the installed dispatcher constants omit the retired search hook',
|
|
1145
|
+
);
|
|
1146
|
+
} finally {
|
|
1147
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
test('a full reinstall removes retired pull request hooks and preserves foreign and recovery state', () => {
|
|
1152
|
+
const sandbox = createSandbox();
|
|
1153
|
+
try {
|
|
1154
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
1155
|
+
const retiredPaths = RETIRED_PULL_REQUEST_HOOK_RELATIVE_PATHS.map(relativePath => (
|
|
1156
|
+
join(sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...relativePath.split('/'))
|
|
1157
|
+
));
|
|
1158
|
+
for (const retiredPath of retiredPaths) {
|
|
1159
|
+
writeFileWithParents(retiredPath, 'an earlier install managed this file\n');
|
|
1160
|
+
}
|
|
1161
|
+
appendManifestFiles(sandbox.manifestPath, retiredPaths);
|
|
1162
|
+
const foreignHookPath = join(
|
|
1163
|
+
sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, 'blocking', 'foreign_pr_hook.py',
|
|
1164
|
+
);
|
|
1165
|
+
const foreignHookBytes = Buffer.from('a user hook the installer never wrote\n', 'utf8');
|
|
1166
|
+
writeFileWithParents(foreignHookPath, foreignHookBytes);
|
|
1167
|
+
const recoveryStatePath = join(
|
|
1168
|
+
sandbox.homeDirectory, 'pending', 'gh_pr_author_swap_session-a.json',
|
|
1169
|
+
);
|
|
1170
|
+
const recoveryStateBytes = Buffer.from('{"original_account":"prior"}\n', 'utf8');
|
|
1171
|
+
writeFileWithParents(recoveryStatePath, recoveryStateBytes);
|
|
1172
|
+
const settings = readSettings(sandbox.claudeDirectory);
|
|
1173
|
+
settings.hooks.PreToolUse.push({
|
|
1174
|
+
matcher: 'Bash',
|
|
1175
|
+
hooks: [
|
|
1176
|
+
...RETIRED_PULL_REQUEST_REGISTRATION_PATHS.map(relativePath => ({
|
|
1177
|
+
type: 'command',
|
|
1178
|
+
command: `python3 "${join(sandbox.claudeDirectory, HOOKS_DIRECTORY_NAME, ...relativePath.split('/')).replace(/\\/g, '/')}"`,
|
|
1179
|
+
})),
|
|
1180
|
+
{ type: 'command', command: `python3 "${foreignHookPath.replace(/\\/g, '/')}"` },
|
|
1181
|
+
],
|
|
1182
|
+
});
|
|
1183
|
+
writeFileSync(
|
|
1184
|
+
join(sandbox.claudeDirectory, SETTINGS_FILE_NAME),
|
|
1185
|
+
JSON.stringify(settings, null, 4) + '\n',
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
1189
|
+
|
|
1190
|
+
const freshManifest = readManifest(sandbox.manifestPath);
|
|
1191
|
+
const allCommands = allHookCommands(readSettings(sandbox.claudeDirectory));
|
|
1192
|
+
for (const retiredPath of retiredPaths) {
|
|
1193
|
+
assert.equal(existsSync(retiredPath), false, `${retiredPath} leaves installed hooks`);
|
|
1194
|
+
assert.equal(freshManifest.files.includes(retiredPath), false);
|
|
1195
|
+
}
|
|
1196
|
+
for (const relativePath of RETIRED_PULL_REQUEST_REGISTRATION_PATHS) {
|
|
1197
|
+
assert.ok(allCommands.every(command => !command.includes(relativePath)));
|
|
1198
|
+
}
|
|
1199
|
+
assert.deepEqual(readFileSync(foreignHookPath), foreignHookBytes);
|
|
1200
|
+
assert.deepEqual(readFileSync(recoveryStatePath), recoveryStateBytes);
|
|
1201
|
+
} finally {
|
|
1202
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1057
1206
|
test('a full reinstall removes a retired hook entry under an event type the current config leaves out', () => {
|
|
1058
1207
|
const sandbox = createSandbox();
|
|
1059
1208
|
try {
|
package/bin/install.test.mjs
CHANGED
|
@@ -228,6 +228,11 @@ test('CORE_SKILLS ships ELI5 as the leaf presentation skill', () => {
|
|
|
228
228
|
assert.equal(INSTALL_GROUPS.core.skills.includes('eli5'), true);
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
test('CORE_SKILLS ships test-runner for explicit Django and Playwright readiness checks', () => {
|
|
232
|
+
assert.ok(CORE_SKILLS.includes('test-runner'));
|
|
233
|
+
assert.equal(INSTALL_GROUPS.core.skills.includes('test-runner'), true);
|
|
234
|
+
});
|
|
235
|
+
|
|
231
236
|
|
|
232
237
|
test('EVER_SHIPPED_SKILL_NAMES retains imagegen so reinstall prunes the retired skill', () => {
|
|
233
238
|
assert.ok(
|
|
@@ -1246,7 +1251,7 @@ const OLD_FOLDED_HOOKS_SETTINGS = {
|
|
|
1246
1251
|
|
|
1247
1252
|
|
|
1248
1253
|
test('FOLDED_HOOK_RELATIVE_PATHS contains folded hooks plus retired entries', () => {
|
|
1249
|
-
assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size,
|
|
1254
|
+
assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 21);
|
|
1250
1255
|
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
|
|
1251
1256
|
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
|
|
1252
1257
|
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
|
|
@@ -1256,16 +1261,22 @@ test('FOLDED_HOOK_RELATIVE_PATHS contains folded hooks plus retired entries', ()
|
|
|
1256
1261
|
|
|
1257
1262
|
|
|
1258
1263
|
test('retired hook registrations stay managed so reinstall removes them', () => {
|
|
1259
|
-
const
|
|
1264
|
+
const retiredPaths = new Set([
|
|
1265
|
+
'session/untracked_repo_detector.py',
|
|
1266
|
+
'session/gh_pr_author_session_cleanup.py',
|
|
1267
|
+
'observability/pr_description_writer_spawn_tracker.py',
|
|
1268
|
+
]);
|
|
1260
1269
|
const shippedHooks = JSON.parse(
|
|
1261
1270
|
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
1262
1271
|
);
|
|
1263
1272
|
const shippedCommands = Object.values(shippedHooks.hooks).flatMap(matcherGroups => (
|
|
1264
1273
|
matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
|
|
1265
1274
|
));
|
|
1266
|
-
assert.deepEqual(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
|
|
1267
|
-
|
|
1268
|
-
|
|
1275
|
+
assert.deepEqual(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS, retiredPaths);
|
|
1276
|
+
for (const retiredPath of retiredPaths) {
|
|
1277
|
+
assert.ok(managedHookScriptRelativePaths({ hooks: {} }).has(retiredPath));
|
|
1278
|
+
assert.ok(shippedCommands.every(command => !command.includes(retiredPath)));
|
|
1279
|
+
}
|
|
1269
1280
|
});
|
|
1270
1281
|
|
|
1271
1282
|
|
|
@@ -1288,6 +1299,11 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
|
|
|
1288
1299
|
];
|
|
1289
1300
|
const retiredHooks = [
|
|
1290
1301
|
'blocking/md_to_html_blocker.py',
|
|
1302
|
+
'blocking/gh_body_arg_blocker.py',
|
|
1303
|
+
'blocking/volatile_path_in_post_blocker.py',
|
|
1304
|
+
'blocking/conventional_pr_title_gate.py',
|
|
1305
|
+
'blocking/gh_pr_author_enforcer.py',
|
|
1306
|
+
'blocking/pr_description_writer_gate.py',
|
|
1291
1307
|
'session/untracked_repo_detector.py',
|
|
1292
1308
|
];
|
|
1293
1309
|
for (const hostedPath of dispatcherHostedHooks) {
|
|
@@ -1480,12 +1496,13 @@ const OLD_POST_FOLDED_HOOKS_SETTINGS = {
|
|
|
1480
1496
|
};
|
|
1481
1497
|
|
|
1482
1498
|
|
|
1483
|
-
test('POST_FOLDED_HOOK_RELATIVE_PATHS contains
|
|
1484
|
-
assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size,
|
|
1499
|
+
test('POST_FOLDED_HOOK_RELATIVE_PATHS contains hosted and retired post-tool hooks', () => {
|
|
1500
|
+
assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 5);
|
|
1485
1501
|
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('validation/mypy_validator.py'));
|
|
1486
1502
|
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/auto_formatter.py'));
|
|
1487
1503
|
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/doc_gist_auto_publish.py'));
|
|
1488
1504
|
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/md_to_html_companion.py'));
|
|
1505
|
+
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('blocking/gh_pr_author_restore.py'));
|
|
1489
1506
|
});
|
|
1490
1507
|
|
|
1491
1508
|
|
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""PreToolUse hook: block writes, durable posts, and commits that carry PII.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
A repository named in ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories``
|
|
18
|
-
list in ``~/.claude/local-identity.json`` skips the staged-commit scan; a
|
|
19
|
-
repository without a readable origin remote is never exempt. Exact values listed
|
|
20
|
-
under ``pii_allowlisted_values`` for a repository's origin slug may pass the
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
Write/Edit/MultiEdit -> scan new disk content
|
|
7
|
+
gh post / GitHub MCP -> scan durable bodies
|
|
8
|
+
git commit -> scan staged blob text, not the commit message
|
|
9
|
+
|
|
10
|
+
``pii_payload_scan`` and ``pii_commit_command`` re-export through this
|
|
11
|
+
entry module so hooks.json wiring and importers stay unchanged.
|
|
12
|
+
``CLAUDE_PII_EXEMPT_REPOS`` or ``pii_exempt_repositories`` in
|
|
13
|
+
``~/.claude/local-identity.json`` skip the staged-commit scan. A repo
|
|
14
|
+
without a readable origin remote is never exempt.
|
|
15
|
+
``pii_allowlisted_values`` for a repository origin slug may pass
|
|
21
16
|
Write/Edit and staged-commit scans in that repository only.
|
|
22
17
|
"""
|
|
23
18
|
|
|
@@ -56,7 +51,7 @@ try:
|
|
|
56
51
|
)
|
|
57
52
|
from pii_scanner import is_path_exempt_from_pii_scan, scan_text_for_pii
|
|
58
53
|
from precommit_code_rules_gate import resolve_repository_root
|
|
59
|
-
from
|
|
54
|
+
from gh_post_body_texts import (
|
|
60
55
|
extract_gh_post_body_texts_for_privacy_gate,
|
|
61
56
|
extract_mcp_body_texts,
|
|
62
57
|
)
|
|
@@ -124,7 +119,8 @@ def list_staged_file_paths(
|
|
|
124
119
|
try:
|
|
125
120
|
completed_process = subprocess.run(
|
|
126
121
|
list(ALL_STAGED_FILES_COMMAND),
|
|
127
|
-
check=False,
|
|
122
|
+
check=False,
|
|
123
|
+
capture_output=True,
|
|
128
124
|
text=True,
|
|
129
125
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
130
126
|
cwd=str(repository_root),
|
|
@@ -148,7 +144,8 @@ def _git_show_staged_blob(
|
|
|
148
144
|
try:
|
|
149
145
|
completed_process = subprocess.run(
|
|
150
146
|
list(ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX) + [staged_blob_reference],
|
|
151
|
-
check=False,
|
|
147
|
+
check=False,
|
|
148
|
+
capture_output=True,
|
|
152
149
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
153
150
|
cwd=str(repository_root),
|
|
154
151
|
)
|
|
@@ -197,9 +194,7 @@ def _scan_staged_path(
|
|
|
197
194
|
repository_root: Path, relative_path: str, all_allowlisted_values: frozenset[str]
|
|
198
195
|
) -> str | None:
|
|
199
196
|
"""Return a deny reason for one staged path, or None when it is clean."""
|
|
200
|
-
staged_text, unscannable_reason = read_staged_file_text(
|
|
201
|
-
repository_root, relative_path
|
|
202
|
-
)
|
|
197
|
+
staged_text, unscannable_reason = read_staged_file_text(repository_root, relative_path)
|
|
203
198
|
if unscannable_reason is not None:
|
|
204
199
|
return unscannable_reason
|
|
205
200
|
if staged_text is None:
|
|
@@ -217,9 +212,9 @@ def _scan_staged_path(
|
|
|
217
212
|
def evaluate_staged_commit(repository_root: Path) -> str | None:
|
|
218
213
|
"""Return a deny reason when staged content carries PII or is unscannable.
|
|
219
214
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
215
|
+
Git list or show failures and unscannable blobs deny the commit. A value
|
|
216
|
+
in the repository's PII allowlist is dropped from the findings, so its
|
|
217
|
+
commits may carry it.
|
|
223
218
|
|
|
224
219
|
Args:
|
|
225
220
|
repository_root: Repository whose index is about to be committed.
|
|
@@ -234,22 +229,18 @@ def evaluate_staged_commit(repository_root: Path) -> str | None:
|
|
|
234
229
|
for each_relative_path in all_relative_paths:
|
|
235
230
|
if is_path_exempt_from_pii_scan(each_relative_path):
|
|
236
231
|
continue
|
|
237
|
-
deny_reason = _scan_staged_path(
|
|
238
|
-
repository_root, each_relative_path, all_allowlisted_values
|
|
239
|
-
)
|
|
232
|
+
deny_reason = _scan_staged_path(repository_root, each_relative_path, all_allowlisted_values)
|
|
240
233
|
if deny_reason is not None:
|
|
241
234
|
return deny_reason
|
|
242
235
|
return None
|
|
243
236
|
|
|
244
237
|
|
|
245
|
-
def _evaluate_commit_staged_content(
|
|
246
|
-
bash_command: str, working_directory: str | None
|
|
247
|
-
) -> str | None:
|
|
238
|
+
def _evaluate_commit_staged_content(bash_command: str, working_directory: str | None) -> str | None:
|
|
248
239
|
"""Scan the staged content of the repository the commit command targets.
|
|
249
240
|
|
|
250
|
-
Resolves the repository from the command's ``-C
|
|
251
|
-
to the session working directory only when the command names none
|
|
252
|
-
removed cwd
|
|
241
|
+
Resolves the repository from the command's ``-C`` or ``cd`` path. Falls
|
|
242
|
+
back to the session working directory only when the command names none. A
|
|
243
|
+
removed cwd does not block a commit that targets a valid repo.
|
|
253
244
|
|
|
254
245
|
Args:
|
|
255
246
|
bash_command: The Bash or PowerShell tool command string.
|
|
@@ -259,9 +250,7 @@ def _evaluate_commit_staged_content(
|
|
|
259
250
|
Deny reason text, or None when the staged content is clean or exempt.
|
|
260
251
|
"""
|
|
261
252
|
command_directory = compose_command_working_directory(bash_command)
|
|
262
|
-
attempted_directory =
|
|
263
|
-
command_directory if command_directory is not None else working_directory
|
|
264
|
-
)
|
|
253
|
+
attempted_directory = command_directory if command_directory is not None else working_directory
|
|
265
254
|
expanded_directory = expand_user_directory(attempted_directory)
|
|
266
255
|
repository_root = resolve_repository_root(expanded_directory)
|
|
267
256
|
if repository_root is None:
|
|
@@ -271,9 +260,7 @@ def _evaluate_commit_staged_content(
|
|
|
271
260
|
return evaluate_staged_commit(repository_root)
|
|
272
261
|
|
|
273
262
|
|
|
274
|
-
def evaluate_bash_command(
|
|
275
|
-
bash_command: str, working_directory: str | None
|
|
276
|
-
) -> str | None:
|
|
263
|
+
def evaluate_bash_command(bash_command: str, working_directory: str | None) -> str | None:
|
|
277
264
|
"""Return a deny reason for a shell gh post or git commit with PII.
|
|
278
265
|
|
|
279
266
|
Args:
|
|
@@ -284,10 +271,8 @@ def evaluate_bash_command(
|
|
|
284
271
|
Returns:
|
|
285
272
|
Deny reason text, or None when the command is clean or out of scope.
|
|
286
273
|
"""
|
|
287
|
-
all_post_bodies, body_file_failure_reason = (
|
|
288
|
-
|
|
289
|
-
bash_command, working_directory=working_directory
|
|
290
|
-
)
|
|
274
|
+
all_post_bodies, body_file_failure_reason = extract_gh_post_body_texts_for_privacy_gate(
|
|
275
|
+
bash_command, working_directory=working_directory
|
|
291
276
|
)
|
|
292
277
|
if body_file_failure_reason is not None:
|
|
293
278
|
return body_file_failure_reason
|
|
@@ -308,9 +293,7 @@ def _evaluate_shell_tool(
|
|
|
308
293
|
return None
|
|
309
294
|
working_directory_value = all_tool_input.get("working_directory")
|
|
310
295
|
working_directory = (
|
|
311
|
-
working_directory_value
|
|
312
|
-
if isinstance(working_directory_value, str)
|
|
313
|
-
else None
|
|
296
|
+
working_directory_value if isinstance(working_directory_value, str) else None
|
|
314
297
|
)
|
|
315
298
|
if working_directory is None:
|
|
316
299
|
cwd_value = payload_by_key.get("cwd")
|
|
@@ -332,9 +315,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
332
315
|
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
333
316
|
all_tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
334
317
|
if tool_name in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
335
|
-
return evaluate_write_edit_payload(
|
|
336
|
-
tool_name, all_tool_input, hook_payload=payload_by_key
|
|
337
|
-
)
|
|
318
|
+
return evaluate_write_edit_payload(tool_name, all_tool_input, hook_payload=payload_by_key)
|
|
338
319
|
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
339
320
|
return evaluate_apply_patch_payload(all_tool_input, hook_payload=payload_by_key)
|
|
340
321
|
if tool_name in ALL_SHELL_TOOL_NAMES:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Behavior tests for the Bash PostToolUse dispatcher.
|
|
2
2
|
|
|
3
3
|
One interpreter start runs every hosted PostToolUse hook a Bash call fires.
|
|
4
|
-
The unit tests pin selection
|
|
4
|
+
The unit tests pin selection and call order with a fake
|
|
5
5
|
runner; the end-to-end test drives the real hosted hooks through a real
|
|
6
6
|
payload and confirms the recorder's own real side effect lands.
|
|
7
7
|
``hooks/conftest.py`` already puts the hooks and blocking directories on
|
|
@@ -21,13 +21,10 @@ from hooks_constants.hosted_hook_runner import HostedHookRun
|
|
|
21
21
|
from tdd_enforcer_parts import content_hash_store
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def
|
|
24
|
+
def test_select_applicable_entries_returns_the_hosted_hook_for_bash() -> None:
|
|
25
25
|
all_entries = select_applicable_entries(BASH_TOOL_NAME)
|
|
26
26
|
all_script_paths = [each_entry.script_relative_path for each_entry in all_entries]
|
|
27
|
-
assert all_script_paths == [
|
|
28
|
-
"blocking/gh_pr_author_restore.py",
|
|
29
|
-
"observability/test_failure_recorder.py",
|
|
30
|
-
]
|
|
27
|
+
assert all_script_paths == ["observability/test_failure_recorder.py"]
|
|
31
28
|
|
|
32
29
|
|
|
33
30
|
def test_select_applicable_entries_returns_none_for_a_write_tool() -> None:
|
|
@@ -48,27 +45,7 @@ def test_dispatch_runs_every_hosted_hook_in_roster_order(
|
|
|
48
45
|
|
|
49
46
|
dispatch('{"tool_name": "Bash"}', BASH_TOOL_NAME)
|
|
50
47
|
|
|
51
|
-
assert all_call_paths == ["
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def test_dispatch_runs_the_later_hook_after_an_earlier_one_crashes(
|
|
55
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
56
|
-
) -> None:
|
|
57
|
-
all_call_paths: list[str] = []
|
|
58
|
-
|
|
59
|
-
def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
|
|
60
|
-
del payload_text
|
|
61
|
-
script_name = Path(script_path).name
|
|
62
|
-
all_call_paths.append(script_name)
|
|
63
|
-
if script_name == "gh_pr_author_restore.py":
|
|
64
|
-
return HostedHookRun(captured_stdout="", did_crash=True)
|
|
65
|
-
return HostedHookRun(captured_stdout="", did_crash=False)
|
|
66
|
-
|
|
67
|
-
monkeypatch.setattr("bash_post_call_dispatcher.run_hook_capturing_output", _fake_run_hook)
|
|
68
|
-
|
|
69
|
-
dispatch('{"tool_name": "Bash"}', BASH_TOOL_NAME)
|
|
70
|
-
|
|
71
|
-
assert all_call_paths == ["gh_pr_author_restore.py", "test_failure_recorder.py"]
|
|
48
|
+
assert all_call_paths == ["test_failure_recorder.py"]
|
|
72
49
|
|
|
73
50
|
|
|
74
51
|
def test_main_exits_zero_and_writes_nothing_to_stdout(
|
|
@@ -85,7 +62,7 @@ def test_main_exits_zero_and_writes_nothing_to_stdout(
|
|
|
85
62
|
|
|
86
63
|
|
|
87
64
|
def test_end_to_end_real_hosted_hooks_record_a_real_pytest_failure(tmp_path: Path) -> None:
|
|
88
|
-
"""
|
|
65
|
+
"""Drive the real recorder through the dispatcher."""
|
|
89
66
|
test_file = tmp_path / "test_orders.py"
|
|
90
67
|
test_file.write_text("def test_fulfill(): assert False\n")
|
|
91
68
|
session_id = "post-dispatcher-session"
|
|
@@ -222,7 +222,6 @@ def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
|
|
|
222
222
|
"blocking/cursor_cli_python_misfire_blocker.py",
|
|
223
223
|
"blocking/unscoped_search_blocker.py",
|
|
224
224
|
"blocking/pii_prevention_blocker.py",
|
|
225
|
-
"blocking/pr_description_writer_gate.py",
|
|
226
225
|
]
|
|
227
226
|
|
|
228
227
|
|
|
@@ -270,9 +269,7 @@ def test_dispatcher_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
|
270
269
|
the live Bash tool path.
|
|
271
270
|
"""
|
|
272
271
|
payload_text = _bash_payload("echo $((cd /tmp) && pwd)")
|
|
273
|
-
decision, _reason = _decision_from_stdout(
|
|
274
|
-
_run_process(_DISPATCHER_SCRIPT, payload_text).stdout
|
|
275
|
-
)
|
|
272
|
+
decision, _reason = _decision_from_stdout(_run_process(_DISPATCHER_SCRIPT, payload_text).stdout)
|
|
276
273
|
assert decision == "deny"
|
|
277
274
|
|
|
278
275
|
|
|
@@ -11,7 +11,7 @@ from code_rules_command_dispatch import ( # noqa: E402
|
|
|
11
11
|
check_unanchored_command_dispatch,
|
|
12
12
|
)
|
|
13
13
|
|
|
14
|
-
GATE_PATH = "packages/claude-dev-env/hooks/blocking/
|
|
14
|
+
GATE_PATH = "packages/claude-dev-env/hooks/blocking/block_main_commit.py"
|
|
15
15
|
|
|
16
16
|
UNANCHORED_SOURCE = (
|
|
17
17
|
"import re\n"
|
|
@@ -84,7 +84,7 @@ def test_ignores_file_outside_hooks_blocking() -> None:
|
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def test_ignores_test_file() -> None:
|
|
87
|
-
test_path = "packages/claude-dev-env/hooks/blocking/
|
|
87
|
+
test_path = "packages/claude-dev-env/hooks/blocking/test_block_main_commit.py"
|
|
88
88
|
assert check_unanchored_command_dispatch(UNANCHORED_SOURCE, test_path) == []
|
|
89
89
|
|
|
90
90
|
|