claude-dev-env 1.93.1 → 1.95.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 (262) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +35 -27
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
  6. package/_shared/pr-loop/CLAUDE.md +1 -0
  7. package/_shared/pr-loop/audit-contract.md +1 -1
  8. package/_shared/pr-loop/gh-payloads.md +3 -3
  9. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  10. package/_shared/pr-loop/scripts/README.md +2 -2
  11. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
  13. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  14. package/_shared/pr-loop/state-schema.md +3 -22
  15. package/bin/CLAUDE.md +11 -2
  16. package/bin/ever-shipped-skills.mjs +70 -0
  17. package/bin/install.mjs +138 -13
  18. package/bin/install.prune.test.mjs +457 -0
  19. package/bin/install.test.mjs +10 -4
  20. package/docs/CODE_RULES.md +1 -1
  21. package/hooks/blocking/CLAUDE.md +2 -0
  22. package/hooks/blocking/code_rules_enforcer.py +4 -0
  23. package/hooks/blocking/code_rules_shared.py +82 -0
  24. package/hooks/blocking/code_rules_test_layout.py +9 -3
  25. package/hooks/blocking/pii_commit_command.py +398 -0
  26. package/hooks/blocking/pii_payload_scan.py +133 -0
  27. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  28. package/hooks/blocking/plain_language_blocker.py +138 -4
  29. package/hooks/blocking/sensitive_file_protector.py +114 -48
  30. package/hooks/blocking/tdd_enforcer.py +9 -2
  31. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  32. package/hooks/blocking/test_code_rules_shared.py +181 -0
  33. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  34. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  35. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  36. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  38. package/hooks/hooks_constants/CLAUDE.md +4 -2
  39. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  40. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  41. package/hooks/hooks_constants/local_identity.py +65 -15
  42. package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
  43. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  44. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  46. package/hooks/hooks_constants/test_local_identity.py +68 -0
  47. package/hooks/pyproject.toml +75 -4
  48. package/hooks/validators/CLAUDE.md +1 -1
  49. package/hooks/validators/README.md +2 -0
  50. package/hooks/validators/python_style_checks.py +114 -136
  51. package/hooks/validators/python_style_helpers.py +95 -0
  52. package/hooks/validators/test_python_style_checks.py +0 -164
  53. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  54. package/hooks/validators/test_python_style_fixes.py +251 -0
  55. package/hooks/validators/test_python_style_helpers.py +125 -0
  56. package/package.json +1 -1
  57. package/rules/CLAUDE.md +1 -0
  58. package/rules/anti-corollary-tests.md +69 -0
  59. package/rules/bdd.md +1 -3
  60. package/rules/code-reviews.md +1 -1
  61. package/rules/gh-paginate.md +1 -1
  62. package/rules/plain-language.md +2 -0
  63. package/scripts/CLAUDE.md +10 -1
  64. package/scripts/Get-SessionAccount.ps1 +210 -0
  65. package/scripts/tests/CLAUDE.md +15 -1
  66. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  67. package/skills/CLAUDE.md +6 -27
  68. package/skills/anthropic-plan/SKILL.md +1 -13
  69. package/skills/autoconverge/CLAUDE.md +5 -2
  70. package/skills/autoconverge/SKILL.md +112 -387
  71. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  72. package/skills/autoconverge/reference/closing-report.md +9 -2
  73. package/skills/autoconverge/reference/convergence.md +26 -22
  74. package/skills/autoconverge/reference/headless-safety.md +44 -0
  75. package/skills/autoconverge/reference/multi-pr.md +88 -0
  76. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  77. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  78. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  79. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  80. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  81. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  82. package/skills/autoconverge/workflow/converge.mjs +286 -96
  83. package/skills/bugteam/CLAUDE.md +1 -1
  84. package/skills/bugteam/CONSTRAINTS.md +3 -3
  85. package/skills/bugteam/PROMPTS.md +8 -19
  86. package/skills/bugteam/SKILL.md +13 -14
  87. package/skills/bugteam/reference/CLAUDE.md +1 -1
  88. package/skills/bugteam/reference/README.md +2 -3
  89. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  90. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  91. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  92. package/skills/bugteam/reference/team-setup.md +11 -11
  93. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  94. package/skills/bugteam/scripts/README.md +20 -8
  95. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  96. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  97. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  98. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  99. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  100. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  101. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  102. package/skills/closeout/SKILL.md +153 -0
  103. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  104. package/skills/closeout/reference/issue-body-templates.md +108 -0
  105. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  106. package/skills/copilot-finding-triage/SKILL.md +80 -28
  107. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  108. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  109. package/skills/everything-search/SKILL.md +1 -2
  110. package/skills/grokify/SKILL.md +58 -0
  111. package/skills/grokify/templates/handoff-template.md +92 -0
  112. package/skills/orchestrator/SKILL.md +35 -25
  113. package/skills/orchestrator-refresh/SKILL.md +15 -11
  114. package/skills/pr-converge/CLAUDE.md +1 -1
  115. package/skills/pr-converge/SKILL.md +132 -233
  116. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  117. package/skills/pr-converge/reference/examples.md +37 -25
  118. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  119. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  120. package/skills/pr-converge/reference/per-tick.md +64 -41
  121. package/skills/pr-converge/reference/state-schema.md +27 -2
  122. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  123. package/skills/pr-converge/scripts/README.md +4 -3
  124. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  125. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  126. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  127. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  128. package/skills/pr-converge/scripts/conftest.py +8 -0
  129. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  130. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  131. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  132. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  133. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  134. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  135. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  136. package/skills/pr-fix-protocol/SKILL.md +20 -20
  137. package/skills/privacy-hygiene/SKILL.md +4 -3
  138. package/skills/reviewer-gates/SKILL.md +26 -8
  139. package/skills/session-log/SKILL.md +11 -12
  140. package/skills/session-tidy/SKILL.md +40 -30
  141. package/skills/skill-builder/CLAUDE.md +13 -10
  142. package/skills/skill-builder/SKILL.md +31 -31
  143. package/skills/skill-builder/references/CLAUDE.md +10 -6
  144. package/skills/skill-builder/references/delegation-map.md +13 -4
  145. package/skills/skill-builder/references/description-field.md +113 -0
  146. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  147. package/skills/skill-builder/references/skill-modularity.md +113 -0
  148. package/skills/skill-builder/references/skill-types.md +4 -0
  149. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  150. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  151. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  152. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  153. package/skills/skill-builder/workflows/new-skill.md +34 -10
  154. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  155. package/skills/team-advisor/SKILL.md +15 -11
  156. package/skills/usage-pause/SKILL.md +10 -14
  157. package/system-prompts/software-engineer.xml +7 -6
  158. package/hooks/validators/test_verify_paths.py +0 -32
  159. package/hooks/validators/verify_paths.py +0 -57
  160. package/skills/bdd-protocol/CLAUDE.md +0 -26
  161. package/skills/bdd-protocol/SKILL.md +0 -30
  162. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  163. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  164. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  165. package/skills/bg-agent/CLAUDE.md +0 -17
  166. package/skills/bg-agent/SKILL.md +0 -69
  167. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  168. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  169. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  170. package/skills/caveman/CLAUDE.md +0 -15
  171. package/skills/caveman/SKILL.md +0 -38
  172. package/skills/code/CLAUDE.md +0 -17
  173. package/skills/code/SKILL.md +0 -178
  174. package/skills/deep-research/CLAUDE.md +0 -17
  175. package/skills/deep-research/SKILL.md +0 -80
  176. package/skills/findbugs/CLAUDE.md +0 -20
  177. package/skills/findbugs/SKILL.md +0 -241
  178. package/skills/fixbugs/CLAUDE.md +0 -19
  179. package/skills/fixbugs/SKILL.md +0 -142
  180. package/skills/gh-paginate/CLAUDE.md +0 -18
  181. package/skills/gh-paginate/SKILL.md +0 -84
  182. package/skills/gotcha/CLAUDE.md +0 -33
  183. package/skills/gotcha/SKILL.md +0 -73
  184. package/skills/implement/CLAUDE.md +0 -27
  185. package/skills/implement/SKILL.md +0 -66
  186. package/skills/implement/scripts/CLAUDE.md +0 -22
  187. package/skills/implement/scripts/append_note.py +0 -133
  188. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  189. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  190. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  191. package/skills/implement/scripts/test_append_note.py +0 -191
  192. package/skills/log-audit/CLAUDE.md +0 -20
  193. package/skills/log-audit/SKILL.md +0 -66
  194. package/skills/log-audit/reference/CLAUDE.md +0 -9
  195. package/skills/log-audit/reference/charter.md +0 -52
  196. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  197. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  198. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  199. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  200. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  201. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  202. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  203. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  204. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  205. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  206. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  207. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  208. package/skills/logifix/CLAUDE.md +0 -36
  209. package/skills/logifix/SKILL.md +0 -69
  210. package/skills/logifix/scripts/CLAUDE.md +0 -16
  211. package/skills/logifix/scripts/logifix.ps1 +0 -205
  212. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  213. package/skills/monitor-open-prs/SKILL.md +0 -88
  214. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  215. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  216. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  217. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  218. package/skills/post-audit-findings/SKILL.md +0 -80
  219. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  220. package/skills/pr-consistency-audit/SKILL.md +0 -116
  221. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  222. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  223. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  224. package/skills/pr-review-responder/CLAUDE.md +0 -35
  225. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  226. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  227. package/skills/pr-review-responder/README.md +0 -168
  228. package/skills/pr-review-responder/SKILL.md +0 -203
  229. package/skills/pr-review-responder/TESTING.md +0 -350
  230. package/skills/pr-scope-resolve/SKILL.md +0 -45
  231. package/skills/pre-compact/CLAUDE.md +0 -24
  232. package/skills/pre-compact/SKILL.md +0 -134
  233. package/skills/qbug/CLAUDE.md +0 -40
  234. package/skills/qbug/SKILL.md +0 -387
  235. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  236. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  237. package/skills/refine/CLAUDE.md +0 -44
  238. package/skills/refine/SKILL.md +0 -262
  239. package/skills/refine/templates/CLAUDE.md +0 -17
  240. package/skills/refine/templates/implementation-notes-template.html +0 -56
  241. package/skills/refine/templates/plan-template.md +0 -60
  242. package/skills/research-mode/CLAUDE.md +0 -35
  243. package/skills/research-mode/SKILL.md +0 -53
  244. package/skills/structure-prompt/CLAUDE.md +0 -42
  245. package/skills/structure-prompt/SKILL.md +0 -44
  246. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  247. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  248. package/skills/structure-prompt/reference/block-classification.md +0 -27
  249. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  250. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  251. package/skills/structure-prompt/reference/cleanup.md +0 -33
  252. package/skills/structure-prompt/reference/constraints.md +0 -33
  253. package/skills/structure-prompt/reference/directives.md +0 -37
  254. package/skills/structure-prompt/reference/examples.md +0 -72
  255. package/skills/structure-prompt/reference/instantiation.md +0 -51
  256. package/skills/structure-prompt/reference/output-contract.md +0 -72
  257. package/skills/structure-prompt/reference/per-category.md +0 -23
  258. package/skills/structure-prompt/reference/persona.md +0 -38
  259. package/skills/structure-prompt/reference/research.md +0 -33
  260. package/skills/structure-prompt/reference/structure.md +0 -28
  261. package/skills/verified-build/CLAUDE.md +0 -33
  262. package/skills/verified-build/SKILL.md +0 -33
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env python3
2
+ """Token-aware git-commit detection reused by ``pii_prevention_blocker``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import shlex
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ _blocking_directory = str(Path(__file__).resolve().parent)
12
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
13
+ if _blocking_directory not in sys.path:
14
+ sys.path.insert(0, _blocking_directory)
15
+ if _hooks_directory not in sys.path:
16
+ sys.path.insert(0, _hooks_directory)
17
+
18
+ from block_main_commit import extract_git_working_directory # noqa: E402
19
+
20
+ from hooks_constants.pii_prevention_constants import ( # noqa: E402
21
+ ALL_BASH_FAMILY_INTERPRETER_BASENAMES,
22
+ ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS,
23
+ ALL_GIT_BINARY_BASENAMES,
24
+ ALL_LEADING_SKIPPABLE_COMMAND_TOKENS,
25
+ ALL_ONE_OPERAND_WRAPPER_TOKENS,
26
+ ALL_SHELL_COMMAND_SEPARATOR_TOKENS,
27
+ ALL_SHELL_INTERPRETER_BASENAMES,
28
+ ALL_SHELL_QUOTE_CHARACTERS,
29
+ ALL_VALUE_TAKING_GIT_OPTIONS,
30
+ DOUBLE_DASH_OPTION_PREFIX,
31
+ ENVIRONMENT_ASSIGNMENT_PATTERN,
32
+ GIT_COMMIT_SUBCOMMAND,
33
+ GIT_OPTION_WITH_VALUE_STEP,
34
+ GIT_WORKING_DIRECTORY_OPTION,
35
+ INLINE_COMMAND_FLAG_CLUSTER_CHARACTER,
36
+ INLINE_COMMAND_TOKEN_JOINER,
37
+ LINE_CONTINUATION_PATTERN,
38
+ POWERSHELL_INLINE_COMMAND_FLAG,
39
+ POWERSHELL_LINE_CONTINUATION_PATTERN,
40
+ SHELL_INLINE_COMMAND_FLAG,
41
+ SINGLE_DASH_OPTION_PREFIX,
42
+ SUBSHELL_GROUP_OPEN_TOKEN,
43
+ )
44
+
45
+
46
+ def _strip_token_edge_quotes(token_text: str) -> str:
47
+ return token_text.strip("\"'")
48
+
49
+
50
+ def _token_basename_lower(token_text: str) -> str:
51
+ stripped_token = _strip_token_edge_quotes(token_text)
52
+ return re.split(r"[\\/]", stripped_token)[-1].lower()
53
+
54
+
55
+ def _token_is_git_binary(token_text: str) -> bool:
56
+ return _token_basename_lower(token_text) in ALL_GIT_BINARY_BASENAMES
57
+
58
+
59
+ def _token_is_shell_interpreter(token_text: str) -> bool:
60
+ return _token_basename_lower(token_text) in ALL_SHELL_INTERPRETER_BASENAMES
61
+
62
+
63
+ def _token_is_bash_family_interpreter(token_text: str) -> bool:
64
+ return _token_basename_lower(token_text) in ALL_BASH_FAMILY_INTERPRETER_BASENAMES
65
+
66
+
67
+ def _token_is_subshell_group_open(token_text: str) -> bool:
68
+ return token_text == SUBSHELL_GROUP_OPEN_TOKEN
69
+
70
+
71
+ def _token_is_skippable_prefix(token_text: str) -> bool:
72
+ if ENVIRONMENT_ASSIGNMENT_PATTERN.match(token_text):
73
+ return True
74
+ return _token_basename_lower(token_text) in ALL_LEADING_SKIPPABLE_COMMAND_TOKENS
75
+
76
+
77
+ def _following_tokens_invoke_commit(all_following_tokens: list[str]) -> bool:
78
+ token_index = 0
79
+ option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
80
+ while token_index < len(all_following_tokens):
81
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
82
+ option_name = each_token
83
+ has_attached_value = False
84
+ if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
85
+ option_name, _, _attached_value = each_token.partition("=")
86
+ has_attached_value = True
87
+ if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
88
+ if has_attached_value:
89
+ token_index += 1
90
+ else:
91
+ token_index += option_with_value_step
92
+ continue
93
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
94
+ token_index += 1
95
+ continue
96
+ return each_token.lower() == GIT_COMMIT_SUBCOMMAND
97
+ return False
98
+
99
+
100
+ def _split_shell_command_segments(all_tokens: list[str]) -> list[list[str]]:
101
+ all_segments: list[list[str]] = [[]]
102
+ shell_separators = ALL_SHELL_COMMAND_SEPARATOR_TOKENS
103
+ for each_token in all_tokens:
104
+ if each_token in shell_separators:
105
+ all_segments.append([])
106
+ continue
107
+ all_segments[-1].append(each_token)
108
+ return all_segments
109
+
110
+
111
+ def _tokenize_shell_command(shell_command_piece: str) -> list[str] | None:
112
+ lexer = shlex.shlex(shell_command_piece, posix=True, punctuation_chars=True)
113
+ lexer.whitespace_split = True
114
+ lexer.escape = ""
115
+ lexer.commenters = ""
116
+ try:
117
+ return list(lexer)
118
+ except ValueError:
119
+ return None
120
+
121
+
122
+ def _fallback_segments_by_physical_line(shell_command_piece: str) -> list[list[str]]:
123
+ all_segments: list[list[str]] = []
124
+ for each_physical_line in shell_command_piece.splitlines():
125
+ line_tokens = each_physical_line.split()
126
+ all_segments.extend(_split_shell_command_segments(line_tokens))
127
+ return all_segments
128
+
129
+
130
+ def _segments_for_piece(shell_command_piece: str) -> list[list[str]]:
131
+ piece_tokens = _tokenize_shell_command(shell_command_piece)
132
+ if piece_tokens is None:
133
+ return _fallback_segments_by_physical_line(shell_command_piece)
134
+ return _split_shell_command_segments(piece_tokens)
135
+
136
+
137
+ def _split_on_unquoted_newlines(shell_command: str) -> list[str]:
138
+ all_pieces: list[str] = []
139
+ current_characters: list[str] = []
140
+ active_quote_character = ""
141
+ for each_character in shell_command:
142
+ if active_quote_character:
143
+ if each_character == active_quote_character:
144
+ active_quote_character = ""
145
+ current_characters.append(each_character)
146
+ continue
147
+ if each_character in ALL_SHELL_QUOTE_CHARACTERS:
148
+ active_quote_character = each_character
149
+ current_characters.append(each_character)
150
+ continue
151
+ if each_character in ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS:
152
+ all_pieces.append("".join(current_characters))
153
+ current_characters = []
154
+ continue
155
+ current_characters.append(each_character)
156
+ all_pieces.append("".join(current_characters))
157
+ return all_pieces
158
+
159
+
160
+ def _all_command_segments(shell_command: str) -> list[list[str]]:
161
+ collapsed_command = LINE_CONTINUATION_PATTERN.sub("", shell_command)
162
+ collapsed_command = POWERSHELL_LINE_CONTINUATION_PATTERN.sub("", collapsed_command)
163
+ all_segments: list[list[str]] = []
164
+ for each_piece in _split_on_unquoted_newlines(collapsed_command):
165
+ all_segments.extend(_segments_for_piece(each_piece))
166
+ return all_segments
167
+
168
+
169
+ def _token_is_leading_skip_target(token_text: str) -> bool:
170
+ return _token_is_git_binary(token_text) or _token_is_shell_interpreter(token_text)
171
+
172
+
173
+ def _token_is_wrapper_option_flag(token_text: str) -> bool:
174
+ return token_text.startswith(SINGLE_DASH_OPTION_PREFIX)
175
+
176
+
177
+ def _wrapper_leading_operand_count(token_text: str) -> int:
178
+ if _token_basename_lower(token_text) in ALL_ONE_OPERAND_WRAPPER_TOKENS:
179
+ return 1
180
+ return 0
181
+
182
+
183
+ def _flag_value_token_follows(all_segment_tokens: list[str], value_index: int) -> bool:
184
+ if value_index >= len(all_segment_tokens):
185
+ return False
186
+ if not _token_is_leading_skip_target(all_segment_tokens[value_index]):
187
+ return True
188
+ return _later_token_is_leading_skip_target(all_segment_tokens, value_index + 1)
189
+
190
+
191
+ def _later_token_is_leading_skip_target(
192
+ all_segment_tokens: list[str], search_start_index: int
193
+ ) -> bool:
194
+ for each_token in all_segment_tokens[search_start_index:]:
195
+ if _token_is_leading_skip_target(each_token):
196
+ return True
197
+ return False
198
+
199
+
200
+ def _skip_leading_noop_tokens(all_segment_tokens: list[str]) -> int:
201
+ token_index = 0
202
+ has_skipped_wrapper_prefix = False
203
+ pending_operand_budget = 0
204
+ while token_index < len(all_segment_tokens):
205
+ each_token = all_segment_tokens[token_index]
206
+ if _token_is_subshell_group_open(each_token):
207
+ token_index += 1
208
+ continue
209
+ if _token_is_skippable_prefix(each_token):
210
+ has_skipped_wrapper_prefix = True
211
+ pending_operand_budget += _wrapper_leading_operand_count(each_token)
212
+ token_index += 1
213
+ continue
214
+ if not has_skipped_wrapper_prefix:
215
+ break
216
+ if _token_is_leading_skip_target(each_token):
217
+ break
218
+ if _token_is_wrapper_option_flag(each_token):
219
+ token_index += 1
220
+ if _flag_value_token_follows(all_segment_tokens, token_index):
221
+ token_index += 1
222
+ continue
223
+ if pending_operand_budget > 0:
224
+ pending_operand_budget -= 1
225
+ token_index += 1
226
+ continue
227
+ break
228
+ return token_index
229
+
230
+
231
+ def _token_is_powershell_command_flag_prefix(lowered_token: str) -> bool:
232
+ if lowered_token == SINGLE_DASH_OPTION_PREFIX:
233
+ return False
234
+ return POWERSHELL_INLINE_COMMAND_FLAG.startswith(lowered_token)
235
+
236
+
237
+ def _token_is_interpreter_inline_command_flag(
238
+ token_text: str, interpreter_allows_short_flag_cluster: bool
239
+ ) -> bool:
240
+ if not token_text.startswith(SINGLE_DASH_OPTION_PREFIX):
241
+ return False
242
+ if token_text.startswith(DOUBLE_DASH_OPTION_PREFIX):
243
+ return False
244
+ lowered_token = token_text.lower()
245
+ if lowered_token == SHELL_INLINE_COMMAND_FLAG:
246
+ return True
247
+ if _token_is_powershell_command_flag_prefix(lowered_token):
248
+ return True
249
+ if not interpreter_allows_short_flag_cluster:
250
+ return False
251
+ clustered_flag_characters = lowered_token[len(SINGLE_DASH_OPTION_PREFIX) :]
252
+ return INLINE_COMMAND_FLAG_CLUSTER_CHARACTER in clustered_flag_characters
253
+
254
+
255
+ def _interpreter_inline_command_invokes_commit(
256
+ interpreter_token: str, all_following_tokens: list[str]
257
+ ) -> bool:
258
+ allows_short_flag_cluster = _token_is_bash_family_interpreter(interpreter_token)
259
+ token_index = 0
260
+ while token_index < len(all_following_tokens):
261
+ each_token = all_following_tokens[token_index]
262
+ if _token_is_interpreter_inline_command_flag(
263
+ each_token, allows_short_flag_cluster
264
+ ):
265
+ argument_index = token_index + 1
266
+ if argument_index >= len(all_following_tokens):
267
+ return False
268
+ inline_command = INLINE_COMMAND_TOKEN_JOINER.join(
269
+ all_following_tokens[argument_index:]
270
+ )
271
+ return is_git_commit_shell_command(inline_command)
272
+ token_index += 1
273
+ return False
274
+
275
+
276
+ def _segment_invokes_git_commit(all_segment_tokens: list[str]) -> bool:
277
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
278
+ if command_index >= len(all_segment_tokens):
279
+ return False
280
+ all_following_tokens = all_segment_tokens[command_index + 1 :]
281
+ command_token = all_segment_tokens[command_index]
282
+ if _token_is_shell_interpreter(command_token):
283
+ return _interpreter_inline_command_invokes_commit(
284
+ command_token, all_following_tokens
285
+ )
286
+ if not _token_is_git_binary(command_token):
287
+ return False
288
+ return _following_tokens_invoke_commit(all_following_tokens)
289
+
290
+
291
+ def is_git_commit_shell_command(shell_command: str) -> bool:
292
+ """Report whether *shell_command* invokes git commit (token-aware).
293
+
294
+ Each segment is read past its leading noise to the real command word::
295
+
296
+ sudo git commit -m x -> skip the wrapper, then match commit
297
+ nice -n 10 git commit -> skip the wrapper and its flag value
298
+ then git commit -m x -> skip the keyword, then match commit
299
+ build & git commit -m x -> split on the background operator
300
+ bash -cx "git commit" -> unwrap the bash cluster
301
+ pwsh -ExecutionPolicy Bypass -Command ... -> skip past the pwsh flag
302
+ (git commit -m x) -> step over the group open
303
+
304
+ Skipped leading tokens: a subshell-group open ``(``, env-assignments, shell
305
+ keywords (then, do, else, elif), and wrapper commands (sudo, env, time,
306
+ nice, xargs, command, stdbuf) together with each wrapper's own option flags,
307
+ flag values, and the single leading operand that timeout and flock take
308
+ before their command. A non-wrapper command word between a wrapper and a
309
+ later git commit stops the scan, so the wrapper's own payload command is
310
+ read rather than the trailing git token. Segments split on unquoted control
311
+ separators (including
312
+ a lone ``&`` background operator) and newlines, and the git binary may be
313
+ path-prefixed and carry global flags (no-verify, config, and
314
+ working-directory) before its subcommand. A shell interpreter is unwrapped
315
+ at its inline-command flag by interpreter family: bash and sh take an
316
+ isolated ``-c`` or any short-flag cluster carrying ``c`` (``-lc``, ``-cx``),
317
+ and PowerShell (pwsh, powershell) takes only ``-Command`` or ``-c``, so a
318
+ leading pwsh flag whose name merely contains ``c`` (``-ExecutionPolicy``,
319
+ ``-NonInteractive``) is stepped over until the real ``-Command`` is reached.
320
+ The inline command's remaining tokens rejoin into one string, so an
321
+ unquoted multi-token command is read whole.
322
+
323
+ Args:
324
+ shell_command: Bash or PowerShell tool command string.
325
+
326
+ Returns:
327
+ True when a command segment invokes git with a commit subcommand.
328
+ """
329
+ if not shell_command or not shell_command.strip():
330
+ return False
331
+ for each_segment in _all_command_segments(shell_command):
332
+ if _segment_invokes_git_commit(each_segment):
333
+ return True
334
+ return False
335
+
336
+
337
+ def _following_tokens_working_directory(all_following_tokens: list[str]) -> str | None:
338
+ token_index = 0
339
+ option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
340
+ while token_index < len(all_following_tokens):
341
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
342
+ if each_token == GIT_WORKING_DIRECTORY_OPTION:
343
+ value_index = token_index + 1
344
+ if value_index >= len(all_following_tokens):
345
+ return None
346
+ return _strip_token_edge_quotes(all_following_tokens[value_index])
347
+ option_name = each_token
348
+ has_attached_value = False
349
+ if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
350
+ option_name, _, _attached_value = each_token.partition("=")
351
+ has_attached_value = True
352
+ if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
353
+ token_index += 1 if has_attached_value else option_with_value_step
354
+ continue
355
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
356
+ token_index += 1
357
+ continue
358
+ return None
359
+ return None
360
+
361
+
362
+ def _segment_git_commit_working_directory(
363
+ all_segment_tokens: list[str],
364
+ ) -> str | None:
365
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
366
+ if command_index >= len(all_segment_tokens):
367
+ return None
368
+ command_token = all_segment_tokens[command_index]
369
+ if not _token_is_git_binary(command_token):
370
+ return None
371
+ all_following_tokens = all_segment_tokens[command_index + 1 :]
372
+ return _following_tokens_working_directory(all_following_tokens)
373
+
374
+
375
+ def extract_git_commit_working_directory(shell_command: str) -> str | None:
376
+ """Return the directory a git-commit command runs in, or None for the CWD.
377
+
378
+ Reads the ``-C`` value off the same commit-invoking segment the detector
379
+ matches, so every git-binary shape the detector recognizes resolves too::
380
+
381
+ git.exe -C /repoA commit -m x -> /repoA
382
+ git -C "C:/repo" commit -m x -> C:/repo
383
+ cd /repoB && git commit -m x -> /repoB (cd/pushd fallback)
384
+ git commit -m x -> None (runs in the CWD)
385
+
386
+ Args:
387
+ shell_command: Bash or PowerShell tool command string.
388
+
389
+ Returns:
390
+ The working directory the commit targets, or None when it uses the CWD.
391
+ """
392
+ for each_segment in _all_command_segments(shell_command):
393
+ if not _segment_invokes_git_commit(each_segment):
394
+ continue
395
+ segment_directory = _segment_git_commit_working_directory(each_segment)
396
+ if segment_directory is not None:
397
+ return segment_directory
398
+ return extract_git_working_directory(shell_command)
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Scan Write/Edit payloads and durable post bodies for high-confidence PII.
3
+
4
+ Reused by ``pii_prevention_blocker``: ``build_deny_reason`` composes the deny
5
+ message, and ``evaluate_write_edit_payload`` / ``evaluate_post_body_texts`` judge
6
+ new file content and durable GitHub post bodies against the pure scanners in
7
+ ``pii_scanner``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
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
+ )
39
+
40
+
41
+ def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
42
+ """Return the deny message listing each finding for *gate_surface*.
43
+
44
+ Args:
45
+ all_findings: Findings returned by ``scan_text_for_pii``.
46
+ gate_surface: Human-readable surface (write, post body, staged commit).
47
+
48
+ Returns:
49
+ Multi-line deny reason for ``permissionDecisionReason``.
50
+ """
51
+ all_lines = [
52
+ CORRECTIVE_MESSAGE_HEADER,
53
+ f"Surface: {gate_surface}",
54
+ ]
55
+ for each_finding in all_findings:
56
+ all_lines.append(
57
+ FINDING_LINE_TEMPLATE.format(
58
+ category=each_finding.category,
59
+ preview=each_finding.preview,
60
+ )
61
+ )
62
+ all_lines.append(CORRECTIVE_MESSAGE_FOOTER)
63
+ message_line_separator = MESSAGE_LINE_SEPARATOR
64
+ return message_line_separator.join(all_lines)
65
+
66
+
67
+ def _collect_write_edit_texts(
68
+ tool_name: str, all_tool_input: dict[str, object]
69
+ ) -> tuple[str, list[str]]:
70
+ raw_file_path = all_tool_input.get("file_path", "")
71
+ file_path = raw_file_path if isinstance(raw_file_path, str) else ""
72
+ if is_path_exempt_from_pii_scan(file_path):
73
+ return file_path, []
74
+ if tool_name == WRITE_TOOL_NAME:
75
+ write_content = all_tool_input.get("content", "")
76
+ if isinstance(write_content, str) and write_content:
77
+ return file_path, [write_content]
78
+ return file_path, []
79
+ 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
88
+ return file_path, []
89
+
90
+
91
+ def _first_findings_in_texts(all_texts: list[str]) -> list[PiiFinding]:
92
+ for each_text in all_texts:
93
+ all_findings = scan_text_for_pii(each_text)
94
+ if all_findings:
95
+ return all_findings
96
+ return []
97
+
98
+
99
+ def evaluate_write_edit_payload(
100
+ tool_name: str, all_tool_input: dict[str, object]
101
+ ) -> str | None:
102
+ """Return a deny reason when Write/Edit/MultiEdit content carries PII.
103
+
104
+ Args:
105
+ tool_name: The intercepted tool name.
106
+ all_tool_input: The tool input mapping.
107
+
108
+ Returns:
109
+ Deny reason text, or None when the write is clean or out of scope.
110
+ """
111
+ if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
112
+ return None
113
+ 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)
119
+
120
+
121
+ def evaluate_post_body_texts(all_body_texts: list[str]) -> str | None:
122
+ """Return a deny reason when any durable post body carries PII.
123
+
124
+ Args:
125
+ all_body_texts: Body strings extracted from a gh or MCP post tool.
126
+
127
+ Returns:
128
+ Deny reason text, or None when every body is clean.
129
+ """
130
+ all_findings = _first_findings_in_texts(all_body_texts)
131
+ if not all_findings:
132
+ return None
133
+ return build_deny_reason(all_findings, "durable GitHub post body")