claude-dev-env 1.94.0 → 2.0.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.
Files changed (280) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +41 -33
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/CLAUDE.md +10 -1
  42. package/bin/ever-shipped-skills.mjs +73 -0
  43. package/bin/expand_home_directory_tokens.mjs +1 -1
  44. package/bin/install.mjs +140 -7
  45. package/bin/install.prune.test.mjs +457 -0
  46. package/docs/CODE_RULES.md +1 -1
  47. package/hooks/advisory/refactor_guard.py +3 -4
  48. package/hooks/blocking/CLAUDE.md +7 -1
  49. package/hooks/blocking/block_main_commit.py +2 -2
  50. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  61. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  62. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  63. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  64. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  65. package/hooks/blocking/code_rules_enforcer.py +4 -0
  66. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  67. package/hooks/blocking/code_rules_optional_params.py +2 -2
  68. package/hooks/blocking/code_rules_shared.py +83 -1
  69. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  70. package/hooks/blocking/code_rules_test_layout.py +9 -3
  71. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  72. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  73. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  74. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  75. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  76. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  77. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  78. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  79. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  80. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  85. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  86. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  87. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  88. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  89. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  90. package/hooks/blocking/pii_payload_scan.py +138 -42
  91. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  92. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  93. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  94. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  95. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  96. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  97. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  98. package/hooks/blocking/plain_language_blocker.py +138 -4
  99. package/hooks/blocking/pr_description_command_parser.py +8 -4
  100. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  101. package/hooks/blocking/sensitive_file_protector.py +114 -48
  102. package/hooks/blocking/tdd_enforcer.py +97 -601
  103. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  104. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  105. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  106. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  107. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  108. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  109. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  110. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  111. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  112. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  113. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  114. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  115. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  116. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  117. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  118. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  119. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  120. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  121. package/hooks/blocking/test_code_rules_shared.py +181 -0
  122. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  123. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  124. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  125. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  126. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  127. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  128. package/hooks/blocking/tests/conftest.py +10 -0
  129. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  130. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  131. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  132. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  133. package/hooks/git-hooks/post_commit.py +3 -4
  134. package/hooks/hooks_constants/CLAUDE.md +4 -2
  135. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  136. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  137. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  138. package/hooks/hooks_constants/local_identity.py +59 -8
  139. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  140. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  141. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  142. package/hooks/hooks_constants/test_local_identity.py +105 -3
  143. package/hooks/pyproject.toml +52 -4
  144. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  145. package/hooks/validation/mypy_validator.py +2 -2
  146. package/hooks/validators/CLAUDE.md +1 -1
  147. package/hooks/validators/README.md +2 -0
  148. package/hooks/validators/health_check.py +1 -0
  149. package/hooks/validators/mypy_integration.py +2 -0
  150. package/hooks/validators/python_style_checks.py +114 -136
  151. package/hooks/validators/python_style_helpers.py +95 -0
  152. package/hooks/validators/ruff_integration.py +3 -0
  153. package/hooks/validators/test_python_style_checks.py +0 -164
  154. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  155. package/hooks/validators/test_python_style_fixes.py +251 -0
  156. package/hooks/validators/test_python_style_helpers.py +125 -0
  157. package/hooks/workflow/auto_formatter.py +5 -4
  158. package/package.json +1 -1
  159. package/rules/CLAUDE.md +1 -0
  160. package/rules/anti-corollary-tests.md +69 -0
  161. package/rules/bdd.md +1 -3
  162. package/rules/code-reviews.md +1 -1
  163. package/rules/gh-paginate.md +1 -1
  164. package/rules/plain-language.md +2 -0
  165. package/scripts/CLAUDE.md +4 -0
  166. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  167. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  168. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  169. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  170. package/scripts/grok_headless_runner.py +294 -0
  171. package/scripts/grok_worker_preflight.py +410 -0
  172. package/scripts/invoke_code_review.py +463 -0
  173. package/scripts/resolve_worker_spawn.py +619 -0
  174. package/scripts/spawn_grok_batch.py +672 -0
  175. package/scripts/test_grok_headless_runner.py +626 -0
  176. package/scripts/test_grok_worker_preflight.py +1054 -0
  177. package/scripts/test_invoke_code_review.py +672 -0
  178. package/scripts/test_resolve_worker_spawn.py +1014 -0
  179. package/scripts/test_spawn_grok_batch.py +1017 -0
  180. package/skills/CLAUDE.md +6 -3
  181. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  182. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  183. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  184. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  185. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  186. package/skills/autoconverge/CLAUDE.md +3 -3
  187. package/skills/autoconverge/SKILL.md +9 -3
  188. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  189. package/skills/autoconverge/reference/convergence.md +33 -11
  190. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  191. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  192. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  193. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  194. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  195. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  196. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  197. package/skills/autoconverge/workflow/converge.mjs +202 -18
  198. package/skills/bugteam/CLAUDE.md +2 -2
  199. package/skills/bugteam/CONSTRAINTS.md +3 -2
  200. package/skills/bugteam/PROMPTS.md +7 -6
  201. package/skills/bugteam/SKILL.md +18 -13
  202. package/skills/bugteam/reference/README.md +2 -3
  203. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  204. package/skills/bugteam/reference/design-rationale.md +1 -1
  205. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  206. package/skills/bugteam/reference/team-setup.md +8 -2
  207. package/skills/closeout/SKILL.md +153 -0
  208. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  209. package/skills/closeout/reference/issue-body-templates.md +108 -0
  210. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  211. package/skills/codex-review/CLAUDE.md +46 -0
  212. package/skills/codex-review/SKILL.md +181 -0
  213. package/skills/codex-review/reference/CLAUDE.md +15 -0
  214. package/skills/codex-review/reference/cli-contract.md +253 -0
  215. package/skills/codex-review/reference/loop-integration.md +118 -0
  216. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  217. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  218. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  219. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  220. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  221. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  222. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  223. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  224. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  225. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  226. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  227. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  228. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  229. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  230. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  231. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  232. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  233. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  234. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  235. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  236. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  237. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  238. package/skills/codex-review/test_skill_scaffold.py +192 -0
  239. package/skills/grok-spawn/CLAUDE.md +28 -0
  240. package/skills/grok-spawn/SKILL.md +226 -0
  241. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  242. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  243. package/skills/grokify/SKILL.md +9 -1
  244. package/skills/grokify/capability-claims.test.mjs +28 -0
  245. package/skills/grokify/evals/README.md +72 -0
  246. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  247. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  248. package/skills/orchestrator/SKILL.md +32 -22
  249. package/skills/orchestrator-refresh/SKILL.md +12 -8
  250. package/skills/pr-converge/CLAUDE.md +1 -1
  251. package/skills/pr-converge/SKILL.md +34 -13
  252. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  253. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  254. package/skills/pr-converge/reference/ground-rules.md +1 -1
  255. package/skills/pr-converge/reference/per-tick.md +130 -42
  256. package/skills/pr-converge/reference/state-schema.md +10 -0
  257. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  258. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  259. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  260. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  261. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  262. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  263. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  264. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  265. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  266. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  267. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  268. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  269. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  270. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  271. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  272. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  273. package/skills/pr-fix-protocol/SKILL.md +65 -0
  274. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  275. package/skills/reviewer-gates/SKILL.md +7 -5
  276. package/skills/skill-builder/references/skill-modularity.md +1 -1
  277. package/skills/team-advisor/SKILL.md +16 -12
  278. package/system-prompts/software-engineer.xml +7 -6
  279. package/hooks/validators/test_verify_paths.py +0 -32
  280. package/hooks/validators/verify_paths.py +0 -57
@@ -0,0 +1,307 @@
1
+ """Survey a directory's package inventories and detect a stale omission.
2
+
3
+ Reads each ``README.md`` / ``CLAUDE.md`` / ``SKILL.md`` beside a file, collects
4
+ the bare filenames they name in backticks, and reports whether a maintained
5
+ inventory omits the file being written.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from hooks_constants.package_inventory_stale_blocker_constants import (
12
+ ALL_EXEMPT_BASENAMES,
13
+ ALL_EXEMPT_DIRECTORY_NAMES,
14
+ ALL_INVENTORY_DOCUMENT_NAMES,
15
+ ALL_PRODUCTION_CODE_EXTENSIONS,
16
+ ALL_TEST_FILE_MARKERS,
17
+ BACKTICK_TOKEN_PATTERN,
18
+ CODE_FENCE_PATTERN,
19
+ GLOB_METACHARACTER_PATTERN,
20
+ MAX_INVENTORY_FILE_BYTES,
21
+ MINIMUM_INVENTORY_ENTRY_COUNT,
22
+ NON_FILENAME_TOKEN_PATTERN,
23
+ PYTHON_FILE_EXTENSION,
24
+ SCRIPTS_SUBDIRECTORY_NAME,
25
+ SKILL_INVENTORY_DOCUMENT_NAME,
26
+ )
27
+
28
+
29
+ def _basename_token(backtick_inner_text: str) -> str | None:
30
+ """Return the bare filename a backticked token names, when it names one.
31
+
32
+ A token names a bare filename when it is a single filename or path token
33
+ carrying a known file extension; a path keeps only its final segment. A
34
+ slash-command, a glob/pattern token, a multi-word command-example span, and
35
+ an extension-less token yield None.
36
+
37
+ Args:
38
+ backtick_inner_text: The text between a backtick pair, stripped.
39
+
40
+ Returns:
41
+ The bare basename the token references, or None when it names no file.
42
+ """
43
+ inner_text = backtick_inner_text.strip()
44
+ if not inner_text or inner_text.startswith("/"):
45
+ return None
46
+ if GLOB_METACHARACTER_PATTERN.search(inner_text) is not None:
47
+ return None
48
+ if NON_FILENAME_TOKEN_PATTERN.search(inner_text) is not None:
49
+ return None
50
+ basename = os.path.basename(inner_text.replace("\\", "/").rstrip("/"))
51
+ if not basename:
52
+ return None
53
+ _, extension = os.path.splitext(basename)
54
+ if not extension:
55
+ return None
56
+ return basename
57
+
58
+
59
+ def _lines_outside_code_fences(inventory_content: str) -> list[str]:
60
+ """Return the inventory lines that sit outside any fenced code block.
61
+
62
+ Args:
63
+ inventory_content: The text of a README.md or CLAUDE.md inventory.
64
+
65
+ Returns:
66
+ The lines that lie outside every code fence, in document order.
67
+ """
68
+ live_lines: list[str] = []
69
+ is_inside_code_fence = False
70
+ for each_line in inventory_content.splitlines():
71
+ if CODE_FENCE_PATTERN.match(each_line) is not None:
72
+ is_inside_code_fence = not is_inside_code_fence
73
+ continue
74
+ if is_inside_code_fence:
75
+ continue
76
+ live_lines.append(each_line)
77
+ return live_lines
78
+
79
+
80
+ def _line_named_basenames(inventory_line: str) -> set[str]:
81
+ """Return the bare filenames one inventory line names in backticks.
82
+
83
+ Args:
84
+ inventory_line: One line drawn from outside any code fence.
85
+
86
+ Returns:
87
+ The set of bare basenames the line's backticked tokens name.
88
+ """
89
+ line_basenames: set[str] = set()
90
+ for each_match in BACKTICK_TOKEN_PATTERN.finditer(inventory_line):
91
+ each_basename = _basename_token(each_match.group(1))
92
+ if each_basename is not None:
93
+ line_basenames.add(each_basename)
94
+ return line_basenames
95
+
96
+
97
+ def inventory_named_basenames(inventory_content: str) -> set[str]:
98
+ """Return every bare filename a package inventory document names in backticks.
99
+
100
+ Lines inside a fenced code block are skipped as example text. Each backticked
101
+ token that names a literal file contributes its basename; a token holding a
102
+ path contributes its final segment.
103
+
104
+ Args:
105
+ inventory_content: The text of a README.md or CLAUDE.md inventory.
106
+
107
+ Returns:
108
+ The set of bare basenames the inventory names.
109
+ """
110
+ named_basenames: set[str] = set()
111
+ for each_line in _lines_outside_code_fences(inventory_content):
112
+ named_basenames |= _line_named_basenames(each_line)
113
+ return named_basenames
114
+
115
+
116
+ def _read_inventory_content(inventory_path: Path) -> str | None:
117
+ """Return the text of an inventory document, or None when it is unreadable.
118
+
119
+ Args:
120
+ inventory_path: The path of the README.md or CLAUDE.md to read.
121
+
122
+ Returns:
123
+ The document text, or None when it is missing, oversized, or undecodable.
124
+ """
125
+ try:
126
+ if inventory_path.stat().st_size > MAX_INVENTORY_FILE_BYTES:
127
+ return None
128
+ return inventory_path.read_text(encoding="utf-8")
129
+ except (OSError, UnicodeDecodeError):
130
+ return None
131
+
132
+
133
+ class _InventorySurvey:
134
+ """The inventory documents found beside a file and the files they name.
135
+
136
+ Attributes:
137
+ present_inventory_names: The inventory document basenames present in the
138
+ directory (``README.md``, ``CLAUDE.md``, and/or ``SKILL.md``).
139
+ named_basenames: Every bare filename the present inventories name.
140
+ """
141
+
142
+ def __init__(
143
+ self, all_present_inventory_names: list[str], all_named_basenames: set[str]
144
+ ) -> None:
145
+ self.present_inventory_names = all_present_inventory_names
146
+ self.named_basenames = all_named_basenames
147
+
148
+
149
+ def survey_directory_inventories(package_directory: Path) -> _InventorySurvey:
150
+ """Return the inventory documents beside a file and the basenames they name.
151
+
152
+ Args:
153
+ package_directory: The directory that holds the file being written.
154
+
155
+ Returns:
156
+ The survey pairing the present inventory document names with the union of
157
+ the basenames they name.
158
+ """
159
+ present_inventory_names: list[str] = []
160
+ named_basenames: set[str] = set()
161
+ for each_inventory_name in sorted(ALL_INVENTORY_DOCUMENT_NAMES):
162
+ inventory_path = package_directory / each_inventory_name
163
+ inventory_content = _read_inventory_content(inventory_path)
164
+ if inventory_content is None:
165
+ continue
166
+ present_inventory_names.append(each_inventory_name)
167
+ named_basenames |= inventory_named_basenames(inventory_content)
168
+ return _InventorySurvey(present_inventory_names, named_basenames)
169
+
170
+
171
+ def _is_test_file(basename: str) -> bool:
172
+ """Return whether *basename* names a test file the inventory need not list.
173
+
174
+ Args:
175
+ basename: The bare filename of the file being written.
176
+
177
+ Returns:
178
+ True when the name matches a test-file shape.
179
+ """
180
+ if basename.startswith("test_") and basename.endswith(PYTHON_FILE_EXTENSION):
181
+ return True
182
+ if basename.endswith("_test" + PYTHON_FILE_EXTENSION):
183
+ return True
184
+ return any(each_marker in basename for each_marker in ALL_TEST_FILE_MARKERS)
185
+
186
+
187
+ def _is_under_exempt_directory(package_directory: Path) -> bool:
188
+ """Return whether the file's directory is itself an exempt directory.
189
+
190
+ Args:
191
+ package_directory: The directory that holds the file being written.
192
+
193
+ Returns:
194
+ True when the directory's own name is an exempt directory name.
195
+ """
196
+ return package_directory.name in ALL_EXEMPT_DIRECTORY_NAMES
197
+
198
+
199
+ def is_inventoried_production_file(file_path: str) -> bool:
200
+ """Return whether *file_path* is a production file an inventory should name.
201
+
202
+ Args:
203
+ file_path: The destination path of the write.
204
+
205
+ Returns:
206
+ True when the file is one an inventory entry should name.
207
+ """
208
+ basename = os.path.basename(file_path)
209
+ _, extension = os.path.splitext(basename)
210
+ if extension.lower() not in ALL_PRODUCTION_CODE_EXTENSIONS:
211
+ return False
212
+ if basename in ALL_EXEMPT_BASENAMES:
213
+ return False
214
+ if _is_test_file(basename):
215
+ return False
216
+ return not _is_under_exempt_directory(Path(file_path).resolve().parent)
217
+
218
+
219
+ def _sibling_named_basenames(package_directory: Path, all_named_basenames: set[str]) -> set[str]:
220
+ """Return the named basenames that exist as files in *package_directory*.
221
+
222
+ Args:
223
+ package_directory: The directory that holds the file being written.
224
+ all_named_basenames: Every bare basename the inventory documents name.
225
+
226
+ Returns:
227
+ The subset of *all_named_basenames* present as a file in the directory.
228
+ """
229
+ sibling_basenames: set[str] = set()
230
+ for each_basename in all_named_basenames:
231
+ if (package_directory / each_basename).is_file():
232
+ sibling_basenames.add(each_basename)
233
+ return sibling_basenames
234
+
235
+
236
+ def _parent_skill_inventory(package_directory: Path) -> _InventorySurvey | None:
237
+ """Return the parent skill ``SKILL.md`` survey for a ``scripts/`` directory.
238
+
239
+ A production file landing in a skill's ``scripts/`` directory is governed by
240
+ the parent ``SKILL.md`` Layout table one level up. Any other directory and a
241
+ missing or unreadable parent ``SKILL.md`` yield None.
242
+
243
+ Args:
244
+ package_directory: The directory that holds the file being written.
245
+
246
+ Returns:
247
+ The parent ``SKILL.md`` survey, or None when there is none to read.
248
+ """
249
+ if package_directory.name != SCRIPTS_SUBDIRECTORY_NAME:
250
+ return None
251
+ skill_inventory_path = package_directory.parent / SKILL_INVENTORY_DOCUMENT_NAME
252
+ inventory_content = _read_inventory_content(skill_inventory_path)
253
+ if inventory_content is None:
254
+ return None
255
+ return _InventorySurvey(
256
+ [SKILL_INVENTORY_DOCUMENT_NAME], inventory_named_basenames(inventory_content)
257
+ )
258
+
259
+
260
+ def _merged_survey_names(
261
+ package_directory: Path, survey: _InventorySurvey
262
+ ) -> tuple[list[str], set[str]]:
263
+ """Return the survey names merged with any parent skill ``SKILL.md`` survey.
264
+
265
+ Args:
266
+ package_directory: The directory that holds the file being written.
267
+ survey: The file's own directory inventory survey.
268
+
269
+ Returns:
270
+ The present inventory names and named basenames, unioned across the
271
+ directory's own inventories and the parent skill Layout table.
272
+ """
273
+ present_inventory_names = list(survey.present_inventory_names)
274
+ named_basenames = set(survey.named_basenames)
275
+ parent_skill_survey = _parent_skill_inventory(package_directory)
276
+ if parent_skill_survey is not None:
277
+ present_inventory_names += parent_skill_survey.present_inventory_names
278
+ named_basenames |= parent_skill_survey.named_basenames
279
+ return present_inventory_names, named_basenames
280
+
281
+
282
+ def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
283
+ """Return the maintained inventory survey a new file is absent from, or None.
284
+
285
+ The survey reports a stale inventory only when at least one inventory document
286
+ is present, those documents name at least the minimum count of on-disk sibling
287
+ files, and no inventory already names this file's basename.
288
+
289
+ Args:
290
+ file_path: The destination path of the write.
291
+
292
+ Returns:
293
+ The inventory survey when the file is a stale omission, or None.
294
+ """
295
+ package_directory = Path(file_path).resolve().parent
296
+ if not package_directory.is_dir():
297
+ return None
298
+ survey = survey_directory_inventories(package_directory)
299
+ present_inventory_names, named_basenames = _merged_survey_names(package_directory, survey)
300
+ if not present_inventory_names:
301
+ return None
302
+ sibling_basenames = _sibling_named_basenames(package_directory, named_basenames)
303
+ if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
304
+ return None
305
+ if os.path.basename(file_path) in named_basenames:
306
+ return None
307
+ return _InventorySurvey(present_inventory_names, sibling_basenames)
@@ -0,0 +1,10 @@
1
+ """Put the blocking and hooks directories on sys.path for the parts tests."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _blocking_directory = str(Path(__file__).resolve().parents[2])
7
+ _hooks_directory = str(Path(__file__).resolve().parents[3])
8
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
9
+ if each_bootstrap_directory not in sys.path:
10
+ sys.path.insert(0, each_bootstrap_directory)
@@ -0,0 +1,38 @@
1
+ """Tests for the package-inventory blocker decision parts module."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from package_inventory_stale_blocker_parts import decision as decision_module
7
+ from package_inventory_stale_blocker_parts.config.inventory_blocker_constants import (
8
+ FILE_FIRST_RETRY_HINT,
9
+ )
10
+ from package_inventory_stale_blocker_parts.decision import (
11
+ build_block_payload,
12
+ deny_stale_inventory,
13
+ )
14
+ from package_inventory_stale_blocker_parts.inventory_detection import _InventorySurvey
15
+
16
+
17
+ def test_deny_stale_inventory_emits_deny(
18
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
19
+ ) -> None:
20
+ monkeypatch.setattr(decision_module, "log_hook_block", lambda **each_argument: None)
21
+ survey = _InventorySurvey(["README.md"], {"alpha.py", "beta.py"})
22
+ deny_stale_inventory("/pkg/gamma.py", survey)
23
+ emitted_payload = json.loads(capsys.readouterr().out)
24
+ assert emitted_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
25
+
26
+
27
+ def test_build_block_payload_names_the_file_and_denies() -> None:
28
+ survey = _InventorySurvey(["README.md"], {"alpha.py", "beta.py"})
29
+ block_payload = build_block_payload("/pkg/gamma.py", survey)
30
+ hook_output = block_payload["hookSpecificOutput"]
31
+ assert hook_output["permissionDecision"] == "deny"
32
+ assert "gamma.py" in hook_output["permissionDecisionReason"]
33
+
34
+
35
+ def test_build_block_payload_appends_retry_hint() -> None:
36
+ survey = _InventorySurvey(["README.md"], {"alpha.py", "beta.py"})
37
+ block_payload = build_block_payload("/pkg/gamma.py", survey)
38
+ assert FILE_FIRST_RETRY_HINT in block_payload["hookSpecificOutput"]["permissionDecisionReason"]
@@ -0,0 +1,61 @@
1
+ """Tests for the package-inventory detection parts module."""
2
+
3
+ from pathlib import Path
4
+
5
+ from package_inventory_stale_blocker_parts.inventory_detection import (
6
+ find_stale_inventory,
7
+ inventory_named_basenames,
8
+ is_inventoried_production_file,
9
+ survey_directory_inventories,
10
+ )
11
+
12
+ README_TWO_FILES = (
13
+ "# Pipeline\n\n"
14
+ "| Path | Role |\n"
15
+ "|---|---|\n"
16
+ "| `dialer_compose.py` | Composes a dialer strip. |\n"
17
+ "| `compose_dialer_cli.py` | CLI for the dialer strip. |\n"
18
+ )
19
+
20
+
21
+ def _package_with_readme(tmp_path: Path, readme_content: str) -> Path:
22
+ package_directory = tmp_path / "package_directory"
23
+ package_directory.mkdir()
24
+ (package_directory / "README.md").write_text(readme_content, encoding="utf-8")
25
+ return package_directory
26
+
27
+
28
+ def test_inventory_named_basenames_reads_table_cells() -> None:
29
+ named_basenames = inventory_named_basenames(README_TWO_FILES)
30
+ assert named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
31
+
32
+
33
+ def test_survey_directory_inventories_unions_named_files(tmp_path: Path) -> None:
34
+ package_directory = _package_with_readme(tmp_path, README_TWO_FILES)
35
+ survey = survey_directory_inventories(package_directory)
36
+ assert survey.present_inventory_names == ["README.md"]
37
+ assert survey.named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
38
+
39
+
40
+ def test_is_inventoried_production_file_accepts_python_module(tmp_path: Path) -> None:
41
+ assert is_inventoried_production_file(str(tmp_path / "dialer_compose.py")) is True
42
+
43
+
44
+ def test_is_inventoried_production_file_rejects_test_file(tmp_path: Path) -> None:
45
+ assert is_inventoried_production_file(str(tmp_path / "test_dialer.py")) is False
46
+
47
+
48
+ def test_find_stale_inventory_reports_omission(tmp_path: Path) -> None:
49
+ package_directory = _package_with_readme(tmp_path, README_TWO_FILES)
50
+ for each_basename in ("dialer_compose.py", "compose_dialer_cli.py"):
51
+ (package_directory / each_basename).write_text("x = 1\n", encoding="utf-8")
52
+ survey = find_stale_inventory(str(package_directory / "seam_continuity.py"))
53
+ assert survey is not None
54
+ assert survey.named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
55
+
56
+
57
+ def test_find_stale_inventory_allows_named_file(tmp_path: Path) -> None:
58
+ package_directory = _package_with_readme(tmp_path, README_TWO_FILES)
59
+ for each_basename in ("dialer_compose.py", "compose_dialer_cli.py"):
60
+ (package_directory / each_basename).write_text("x = 1\n", encoding="utf-8")
61
+ assert find_stale_inventory(str(package_directory / "compose_dialer_cli.py")) is None
@@ -12,30 +12,39 @@ from __future__ import annotations
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
- _blocking_directory = str(Path(__file__).resolve().parent)
16
- _hooks_directory = str(Path(__file__).resolve().parent.parent)
17
- if _blocking_directory not in sys.path:
18
- sys.path.insert(0, _blocking_directory)
19
- if _hooks_directory not in sys.path:
20
- sys.path.insert(0, _hooks_directory)
21
-
22
- from pii_scanner import ( # noqa: E402
23
- PiiFinding,
24
- is_path_exempt_from_pii_scan,
25
- scan_text_for_pii,
26
- )
27
-
28
- from hooks_constants.multi_edit_reconstruction import edits_for_tool # noqa: E402
29
- from hooks_constants.pii_prevention_constants import ( # noqa: E402
30
- ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
31
- CORRECTIVE_MESSAGE_FOOTER,
32
- CORRECTIVE_MESSAGE_HEADER,
33
- EDIT_TOOL_NAME,
34
- FINDING_LINE_TEMPLATE,
35
- MESSAGE_LINE_SEPARATOR,
36
- MULTI_EDIT_TOOL_NAME,
37
- WRITE_TOOL_NAME,
38
- )
15
+ try:
16
+ _blocking_directory = str(Path(__file__).resolve().parent)
17
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
18
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
19
+ if each_bootstrap_directory not in sys.path:
20
+ sys.path.insert(0, each_bootstrap_directory)
21
+ from pii_prevention_blocker_parts.repository_exemption import (
22
+ repository_allowlisted_values,
23
+ )
24
+ from pii_scanner import (
25
+ PiiFinding,
26
+ is_path_exempt_from_pii_scan,
27
+ scan_text_for_pii,
28
+ )
29
+ from precommit_code_rules_gate import resolve_repository_root
30
+
31
+ from hooks_constants.local_identity import pii_allowlisted_values_by_repository
32
+ from hooks_constants.multi_edit_reconstruction import edits_for_tool
33
+ from hooks_constants.pii_prevention_constants import (
34
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
35
+ CORRECTIVE_MESSAGE_FOOTER,
36
+ CORRECTIVE_MESSAGE_HEADER,
37
+ EDIT_TOOL_NAME,
38
+ FINDING_LINE_TEMPLATE,
39
+ MESSAGE_LINE_SEPARATOR,
40
+ MULTI_EDIT_TOOL_NAME,
41
+ WRITE_TOOL_NAME,
42
+ )
43
+ except ImportError as import_error:
44
+ raise ImportError(
45
+ "pii_payload_scan: cannot import its sibling modules; "
46
+ "ensure the blocking and hooks directories are importable."
47
+ ) from import_error
39
48
 
40
49
 
41
50
  def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
@@ -64,6 +73,20 @@ def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
64
73
  return message_line_separator.join(all_lines)
65
74
 
66
75
 
76
+ def _new_strings_from_edits(
77
+ tool_name: str, all_tool_input: dict[str, object]
78
+ ) -> list[str]:
79
+ """Return each non-empty ``new_string`` from an Edit/MultiEdit payload."""
80
+ all_texts: list[str] = []
81
+ for each_edit in edits_for_tool(tool_name, all_tool_input):
82
+ if not isinstance(each_edit, dict):
83
+ continue
84
+ new_string = each_edit.get("new_string", "")
85
+ if isinstance(new_string, str) and new_string:
86
+ all_texts.append(new_string)
87
+ return all_texts
88
+
89
+
67
90
  def _collect_write_edit_texts(
68
91
  tool_name: str, all_tool_input: dict[str, object]
69
92
  ) -> tuple[str, list[str]]:
@@ -77,33 +100,87 @@ def _collect_write_edit_texts(
77
100
  return file_path, [write_content]
78
101
  return file_path, []
79
102
  if tool_name in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
80
- all_texts: list[str] = []
81
- for each_edit in edits_for_tool(tool_name, all_tool_input):
82
- if not isinstance(each_edit, dict):
83
- continue
84
- new_string = each_edit.get("new_string", "")
85
- if isinstance(new_string, str) and new_string:
86
- all_texts.append(new_string)
87
- return file_path, all_texts
103
+ return file_path, _new_strings_from_edits(tool_name, all_tool_input)
88
104
  return file_path, []
89
105
 
90
106
 
91
- def _first_findings_in_texts(all_texts: list[str]) -> list[PiiFinding]:
107
+ def _findings_after_allowlist(
108
+ text: str, all_allowlisted_values: frozenset[str]
109
+ ) -> list[PiiFinding]:
110
+ """Return PII findings in *text* whose matched text is not allowlisted."""
111
+ return [
112
+ each_finding
113
+ for each_finding in scan_text_for_pii(text)
114
+ if each_finding.matched_text not in all_allowlisted_values
115
+ ]
116
+
117
+
118
+ def _first_findings_in_texts(
119
+ all_texts: list[str], all_allowlisted_values: frozenset[str] = frozenset()
120
+ ) -> list[PiiFinding]:
92
121
  for each_text in all_texts:
93
- all_findings = scan_text_for_pii(each_text)
122
+ all_findings = _findings_after_allowlist(each_text, all_allowlisted_values)
94
123
  if all_findings:
95
124
  return all_findings
96
125
  return []
97
126
 
98
127
 
128
+ def _existing_ancestor_directory(file_path: str) -> str | None:
129
+ """Return the nearest existing ancestor directory of *file_path*, or None.
130
+
131
+ PreToolUse runs before Write creates nested parents, so the immediate parent
132
+ may not exist yet; git resolution needs a directory that is already on disk.
133
+ """
134
+ starting_directory = Path(file_path).parent
135
+ for each_ancestor in (starting_directory, *starting_directory.parents):
136
+ if each_ancestor.exists():
137
+ return str(each_ancestor)
138
+ return None
139
+
140
+
141
+ def _write_path_allowlisted_values(file_path: str) -> frozenset[str]:
142
+ """Return the exact values allowlisted for the repository *file_path* sits in.
143
+
144
+ ::
145
+
146
+ no path, or no allowlist configured -> frozenset() (no git call)
147
+ path inside an allowlisted repo tree -> that repo's frozenset of values
148
+
149
+ Args:
150
+ file_path: The write target path whose repository keys the allowlist.
151
+
152
+ Returns:
153
+ The exact values the repository allows past the scan, or an empty set.
154
+ """
155
+ if not file_path:
156
+ return frozenset()
157
+ if not pii_allowlisted_values_by_repository():
158
+ return frozenset()
159
+ resolution_directory = _existing_ancestor_directory(file_path)
160
+ if resolution_directory is None:
161
+ return frozenset()
162
+ repository_root = resolve_repository_root(resolution_directory)
163
+ if repository_root is None:
164
+ return frozenset()
165
+ return repository_allowlisted_values(repository_root)
166
+
167
+
99
168
  def evaluate_write_edit_payload(
100
- tool_name: str, all_tool_input: dict[str, object]
169
+ tool_name: str,
170
+ all_tool_input: dict[str, object],
171
+ all_allowlisted_values: frozenset[str] = frozenset(),
101
172
  ) -> str | None:
102
173
  """Return a deny reason when Write/Edit/MultiEdit content carries PII.
103
174
 
175
+ A value in the target repository's PII allowlist is dropped from the
176
+ findings, so a write under that repository's tree may carry it. Repository
177
+ resolution for that allowlist runs only after a raw PII hit.
178
+
104
179
  Args:
105
180
  tool_name: The intercepted tool name.
106
181
  all_tool_input: The tool input mapping.
182
+ all_allowlisted_values: Extra exact values allowed past the scan,
183
+ unioned with the target repository's own allowlist.
107
184
 
108
185
  Returns:
109
186
  Deny reason text, or None when the write is clean or out of scope.
@@ -111,23 +188,42 @@ def evaluate_write_edit_payload(
111
188
  if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
112
189
  return None
113
190
  file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
114
- all_findings = _first_findings_in_texts(all_texts)
115
- if not all_findings:
116
- return None
117
- gate_surface = f"file write ({file_path or 'unknown path'})"
118
- return build_deny_reason(all_findings, gate_surface)
191
+ resolved_allowlisted_values: frozenset[str] | None = None
192
+ for each_text in all_texts:
193
+ all_raw_findings = scan_text_for_pii(each_text)
194
+ if not all_raw_findings:
195
+ continue
196
+ if resolved_allowlisted_values is None:
197
+ resolved_allowlisted_values = (
198
+ all_allowlisted_values | _write_path_allowlisted_values(file_path)
199
+ )
200
+ all_findings = [
201
+ each_finding
202
+ for each_finding in all_raw_findings
203
+ if each_finding.matched_text not in resolved_allowlisted_values
204
+ ]
205
+ if all_findings:
206
+ gate_surface = f"file write ({file_path or 'unknown path'})"
207
+ return build_deny_reason(all_findings, gate_surface)
208
+ return None
119
209
 
120
210
 
121
- def evaluate_post_body_texts(all_body_texts: list[str]) -> str | None:
211
+ def evaluate_post_body_texts(
212
+ all_body_texts: list[str],
213
+ all_allowlisted_values: frozenset[str] = frozenset(),
214
+ ) -> str | None:
122
215
  """Return a deny reason when any durable post body carries PII.
123
216
 
124
217
  Args:
125
218
  all_body_texts: Body strings extracted from a gh or MCP post tool.
219
+ all_allowlisted_values: Exact values the caller's repository allows;
220
+ a finding matching one is not a finding. Callers that pass
221
+ nothing keep every post fully scanned.
126
222
 
127
223
  Returns:
128
224
  Deny reason text, or None when every body is clean.
129
225
  """
130
- all_findings = _first_findings_in_texts(all_body_texts)
226
+ all_findings = _first_findings_in_texts(all_body_texts, all_allowlisted_values)
131
227
  if not all_findings:
132
228
  return None
133
229
  return build_deny_reason(all_findings, "durable GitHub post body")