claude-dev-env 1.92.0 → 1.93.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 (216) hide show
  1. package/CLAUDE.md +5 -39
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +7 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/rules/testing.md +0 -2
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +184 -0
  179. package/skills/orchestrator-refresh/SKILL.md +37 -0
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +32 -164
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
  216. package/skills/team-advisor-refresh/SKILL.md +0 -25
@@ -0,0 +1,781 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block writes, durable posts, and commits that carry PII.
3
+
4
+ Surfaces guarded:
5
+
6
+ - Write / Edit / MultiEdit — new content about to land on disk
7
+ - Bash / PowerShell ``gh`` post subcommands and GitHub MCP post tools — durable
8
+ bodies
9
+ - Bash / PowerShell ``git commit`` (including ``git.exe`` and flag forms) —
10
+ staged file contents about to become history. Commit message bodies are out
11
+ of scope; only staged blob text is scanned.
12
+
13
+ Detection reuses the pure scanners in ``pii_scanner`` and the post-body
14
+ extraction helpers already used by ``volatile_path_in_post_blocker``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import shlex
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ _blocking_directory = str(Path(__file__).resolve().parent)
27
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
28
+ if _blocking_directory not in sys.path:
29
+ sys.path.insert(0, _blocking_directory)
30
+ if _hooks_directory not in sys.path:
31
+ sys.path.insert(0, _hooks_directory)
32
+
33
+ from block_main_commit import ( # noqa: E402
34
+ extract_git_working_directory,
35
+ resolve_directory,
36
+ )
37
+ from pii_scanner import PiiFinding, is_path_exempt_from_pii_scan, scan_text_for_pii # noqa: E402
38
+ from precommit_code_rules_gate import ( # noqa: E402
39
+ resolve_repository_root,
40
+ )
41
+ from volatile_path_in_post_blocker import ( # noqa: E402
42
+ extract_gh_post_body_texts_for_privacy_gate,
43
+ extract_mcp_body_texts,
44
+ )
45
+
46
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
47
+ from hooks_constants.multi_edit_reconstruction import edits_for_tool # noqa: E402
48
+ from hooks_constants.pii_prevention_constants import ( # noqa: E402
49
+ ALL_BASH_FAMILY_INTERPRETER_BASENAMES,
50
+ ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS,
51
+ ALL_GIT_BINARY_BASENAMES,
52
+ ALL_LEADING_SKIPPABLE_COMMAND_TOKENS,
53
+ ALL_ONE_OPERAND_WRAPPER_TOKENS,
54
+ ALL_SHELL_COMMAND_SEPARATOR_TOKENS,
55
+ ALL_SHELL_INTERPRETER_BASENAMES,
56
+ ALL_SHELL_QUOTE_CHARACTERS,
57
+ ALL_SHELL_TOOL_NAMES,
58
+ ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
59
+ ALL_STAGED_FILES_COMMAND,
60
+ ALL_VALUE_TAKING_GIT_OPTIONS,
61
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
62
+ BODY_FILE_ENCODING,
63
+ CORRECTIVE_MESSAGE_FOOTER,
64
+ CORRECTIVE_MESSAGE_HEADER,
65
+ DOUBLE_DASH_OPTION_PREFIX,
66
+ EDIT_TOOL_NAME,
67
+ ENVIRONMENT_ASSIGNMENT_PATTERN,
68
+ FINDING_LINE_TEMPLATE,
69
+ GIT_COMMAND_TIMEOUT_SECONDS,
70
+ GIT_COMMIT_SUBCOMMAND,
71
+ GIT_OPTION_WITH_VALUE_STEP,
72
+ GIT_WORKING_DIRECTORY_OPTION,
73
+ HOOK_SCRIPT_BASENAME,
74
+ INLINE_COMMAND_FLAG_CLUSTER_CHARACTER,
75
+ INLINE_COMMAND_TOKEN_JOINER,
76
+ LINE_CONTINUATION_PATTERN,
77
+ MAXIMUM_STAGED_FILE_BYTES,
78
+ MCP_GITHUB_TOOL_PREFIX,
79
+ MESSAGE_LINE_SEPARATOR,
80
+ MULTI_EDIT_TOOL_NAME,
81
+ NULL_BYTE_MARKER,
82
+ POWERSHELL_INLINE_COMMAND_FLAG,
83
+ POWERSHELL_LINE_CONTINUATION_PATTERN,
84
+ REPOSITORY_ROOT_UNRESOLVED_REASON,
85
+ SHELL_INLINE_COMMAND_FLAG,
86
+ SINGLE_DASH_OPTION_PREFIX,
87
+ STAGED_BLOB_PREFIX,
88
+ STAGED_BLOB_REASON_DECODE_FAILED,
89
+ STAGED_BLOB_REASON_GIT_SHOW_FAILED,
90
+ STAGED_BLOB_REASON_NULL_BYTES,
91
+ STAGED_BLOB_REASON_OVERSIZED,
92
+ STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE,
93
+ STAGED_LIST_FAILURE_REASON,
94
+ SUBSHELL_GROUP_OPEN_TOKEN,
95
+ WRITE_TOOL_NAME,
96
+ )
97
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
98
+ read_hook_input_dictionary_from_stdin,
99
+ )
100
+
101
+
102
+ def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
103
+ """Return the deny message listing each finding for *gate_surface*.
104
+
105
+ Args:
106
+ all_findings: Findings returned by ``scan_text_for_pii``.
107
+ gate_surface: Human-readable surface (write, post body, staged commit).
108
+
109
+ Returns:
110
+ Multi-line deny reason for ``permissionDecisionReason``.
111
+ """
112
+ all_lines = [
113
+ CORRECTIVE_MESSAGE_HEADER,
114
+ f"Surface: {gate_surface}",
115
+ ]
116
+ for each_finding in all_findings:
117
+ all_lines.append(
118
+ FINDING_LINE_TEMPLATE.format(
119
+ category=each_finding.category,
120
+ preview=each_finding.preview,
121
+ )
122
+ )
123
+ all_lines.append(CORRECTIVE_MESSAGE_FOOTER)
124
+ message_line_separator = MESSAGE_LINE_SEPARATOR
125
+ return message_line_separator.join(all_lines)
126
+
127
+
128
+ def _collect_write_edit_texts(
129
+ tool_name: str, all_tool_input: dict[str, object]
130
+ ) -> tuple[str, list[str]]:
131
+ raw_file_path = all_tool_input.get("file_path", "")
132
+ file_path = raw_file_path if isinstance(raw_file_path, str) else ""
133
+ if is_path_exempt_from_pii_scan(file_path):
134
+ return file_path, []
135
+ if tool_name == WRITE_TOOL_NAME:
136
+ write_content = all_tool_input.get("content", "")
137
+ if isinstance(write_content, str) and write_content:
138
+ return file_path, [write_content]
139
+ return file_path, []
140
+ if tool_name in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
141
+ all_texts: list[str] = []
142
+ for each_edit in edits_for_tool(tool_name, all_tool_input):
143
+ if not isinstance(each_edit, dict):
144
+ continue
145
+ new_string = each_edit.get("new_string", "")
146
+ if isinstance(new_string, str) and new_string:
147
+ all_texts.append(new_string)
148
+ return file_path, all_texts
149
+ return file_path, []
150
+
151
+
152
+ def _first_findings_in_texts(all_texts: list[str]) -> list[PiiFinding]:
153
+ for each_text in all_texts:
154
+ all_findings = scan_text_for_pii(each_text)
155
+ if all_findings:
156
+ return all_findings
157
+ return []
158
+
159
+
160
+ def evaluate_write_edit_payload(
161
+ tool_name: str, all_tool_input: dict[str, object]
162
+ ) -> str | None:
163
+ """Return a deny reason when Write/Edit/MultiEdit content carries PII.
164
+
165
+ Args:
166
+ tool_name: The intercepted tool name.
167
+ all_tool_input: The tool input mapping.
168
+
169
+ Returns:
170
+ Deny reason text, or None when the write is clean or out of scope.
171
+ """
172
+ if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
173
+ return None
174
+ file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
175
+ all_findings = _first_findings_in_texts(all_texts)
176
+ if not all_findings:
177
+ return None
178
+ gate_surface = f"file write ({file_path or 'unknown path'})"
179
+ return build_deny_reason(all_findings, gate_surface)
180
+
181
+
182
+ def evaluate_post_body_texts(all_body_texts: list[str]) -> str | None:
183
+ """Return a deny reason when any durable post body carries PII.
184
+
185
+ Args:
186
+ all_body_texts: Body strings extracted from a gh or MCP post tool.
187
+
188
+ Returns:
189
+ Deny reason text, or None when every body is clean.
190
+ """
191
+ all_findings = _first_findings_in_texts(all_body_texts)
192
+ if not all_findings:
193
+ return None
194
+ return build_deny_reason(all_findings, "durable GitHub post body")
195
+
196
+
197
+ def list_staged_file_paths(
198
+ repository_root: Path,
199
+ ) -> tuple[list[str] | None, str | None]:
200
+ """List staged non-deleted paths, or report a list failure.
201
+
202
+ Args:
203
+ repository_root: Repository root used as the git working directory.
204
+
205
+ Returns:
206
+ ``(paths, None)`` on success, or ``(None, deny_reason)`` when the
207
+ staged list cannot be read (fail-closed for commit gating).
208
+ """
209
+ try:
210
+ completed_process = subprocess.run(
211
+ list(ALL_STAGED_FILES_COMMAND),
212
+ capture_output=True,
213
+ text=True,
214
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
215
+ cwd=str(repository_root),
216
+ )
217
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
218
+ return None, STAGED_LIST_FAILURE_REASON
219
+ if completed_process.returncode != 0:
220
+ return None, STAGED_LIST_FAILURE_REASON
221
+ all_paths = [
222
+ each_line.strip()
223
+ for each_line in completed_process.stdout.splitlines()
224
+ if each_line.strip()
225
+ ]
226
+ return all_paths, None
227
+
228
+
229
+ def read_staged_file_text(
230
+ repository_root: Path, relative_path: str
231
+ ) -> tuple[str | None, str | None]:
232
+ """Return staged blob text, or report why the blob is unscannable.
233
+
234
+ Args:
235
+ repository_root: Repository root for the git show working directory.
236
+ relative_path: Repository-relative path of the staged file.
237
+
238
+ Returns:
239
+ ``(text, None)`` when the blob is scannable UTF-8 text, or
240
+ ``(None, deny_reason)`` when the blob cannot be scanned (fail-closed).
241
+ """
242
+ staged_blob_reference = STAGED_BLOB_PREFIX + relative_path
243
+ try:
244
+ completed_process = subprocess.run(
245
+ list(ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX) + [staged_blob_reference],
246
+ capture_output=True,
247
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
248
+ cwd=str(repository_root),
249
+ )
250
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
251
+ return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
252
+ relative_path=relative_path,
253
+ reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
254
+ )
255
+ if completed_process.returncode != 0:
256
+ return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
257
+ relative_path=relative_path,
258
+ reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
259
+ )
260
+ raw_bytes = completed_process.stdout
261
+ if len(raw_bytes) > MAXIMUM_STAGED_FILE_BYTES:
262
+ return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
263
+ relative_path=relative_path,
264
+ reason=STAGED_BLOB_REASON_OVERSIZED,
265
+ )
266
+ if NULL_BYTE_MARKER in raw_bytes:
267
+ return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
268
+ relative_path=relative_path,
269
+ reason=STAGED_BLOB_REASON_NULL_BYTES,
270
+ )
271
+ try:
272
+ return raw_bytes.decode(BODY_FILE_ENCODING), None
273
+ except UnicodeDecodeError:
274
+ return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
275
+ relative_path=relative_path,
276
+ reason=STAGED_BLOB_REASON_DECODE_FAILED,
277
+ )
278
+
279
+
280
+ def evaluate_staged_commit(
281
+ repository_root: Path,
282
+ ) -> str | None:
283
+ """Return a deny reason when staged content carries PII or is unscannable.
284
+
285
+ Fail-closed: git list/show failures and unscannable blobs deny the commit
286
+ rather than treating unread content as clean.
287
+
288
+ Args:
289
+ repository_root: Repository whose index is about to be committed.
290
+
291
+ Returns:
292
+ Deny reason text, or None when every scannable staged path is clean.
293
+ """
294
+ all_relative_paths, list_failure_reason = list_staged_file_paths(repository_root)
295
+ if list_failure_reason is not None or all_relative_paths is None:
296
+ return list_failure_reason or STAGED_LIST_FAILURE_REASON
297
+ for each_relative_path in all_relative_paths:
298
+ if is_path_exempt_from_pii_scan(each_relative_path):
299
+ continue
300
+ staged_text, unscannable_reason = read_staged_file_text(
301
+ repository_root, each_relative_path
302
+ )
303
+ if unscannable_reason is not None:
304
+ return unscannable_reason
305
+ if staged_text is None:
306
+ return STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
307
+ relative_path=each_relative_path,
308
+ reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
309
+ )
310
+ all_findings = scan_text_for_pii(staged_text)
311
+ if all_findings:
312
+ gate_surface = f"staged commit ({each_relative_path})"
313
+ return build_deny_reason(all_findings, gate_surface)
314
+ return None
315
+
316
+
317
+ def _strip_token_edge_quotes(token_text: str) -> str:
318
+ return token_text.strip("\"'")
319
+
320
+
321
+ def _token_basename_lower(token_text: str) -> str:
322
+ stripped_token = _strip_token_edge_quotes(token_text)
323
+ return re.split(r"[\\/]", stripped_token)[-1].lower()
324
+
325
+
326
+ def _token_is_git_binary(token_text: str) -> bool:
327
+ return _token_basename_lower(token_text) in ALL_GIT_BINARY_BASENAMES
328
+
329
+
330
+ def _token_is_shell_interpreter(token_text: str) -> bool:
331
+ return _token_basename_lower(token_text) in ALL_SHELL_INTERPRETER_BASENAMES
332
+
333
+
334
+ def _token_is_bash_family_interpreter(token_text: str) -> bool:
335
+ return _token_basename_lower(token_text) in ALL_BASH_FAMILY_INTERPRETER_BASENAMES
336
+
337
+
338
+ def _token_is_subshell_group_open(token_text: str) -> bool:
339
+ return token_text == SUBSHELL_GROUP_OPEN_TOKEN
340
+
341
+
342
+ def _token_is_skippable_prefix(token_text: str) -> bool:
343
+ if ENVIRONMENT_ASSIGNMENT_PATTERN.match(token_text):
344
+ return True
345
+ return _token_basename_lower(token_text) in ALL_LEADING_SKIPPABLE_COMMAND_TOKENS
346
+
347
+
348
+ def _following_tokens_invoke_commit(all_following_tokens: list[str]) -> bool:
349
+ token_index = 0
350
+ option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
351
+ while token_index < len(all_following_tokens):
352
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
353
+ option_name = each_token
354
+ has_attached_value = False
355
+ if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
356
+ option_name, _, _attached_value = each_token.partition("=")
357
+ has_attached_value = True
358
+ if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
359
+ if has_attached_value:
360
+ token_index += 1
361
+ else:
362
+ token_index += option_with_value_step
363
+ continue
364
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
365
+ token_index += 1
366
+ continue
367
+ return each_token.lower() == GIT_COMMIT_SUBCOMMAND
368
+ return False
369
+
370
+
371
+ def _split_shell_command_segments(all_tokens: list[str]) -> list[list[str]]:
372
+ all_segments: list[list[str]] = [[]]
373
+ shell_separators = ALL_SHELL_COMMAND_SEPARATOR_TOKENS
374
+ for each_token in all_tokens:
375
+ if each_token in shell_separators:
376
+ all_segments.append([])
377
+ continue
378
+ all_segments[-1].append(each_token)
379
+ return all_segments
380
+
381
+
382
+ def _tokenize_shell_command(shell_command_piece: str) -> list[str] | None:
383
+ lexer = shlex.shlex(shell_command_piece, posix=True, punctuation_chars=True)
384
+ lexer.whitespace_split = True
385
+ lexer.escape = ""
386
+ lexer.commenters = ""
387
+ try:
388
+ return list(lexer)
389
+ except ValueError:
390
+ return None
391
+
392
+
393
+ def _fallback_segments_by_physical_line(shell_command_piece: str) -> list[list[str]]:
394
+ all_segments: list[list[str]] = []
395
+ for each_physical_line in shell_command_piece.splitlines():
396
+ line_tokens = each_physical_line.split()
397
+ all_segments.extend(_split_shell_command_segments(line_tokens))
398
+ return all_segments
399
+
400
+
401
+ def _segments_for_piece(shell_command_piece: str) -> list[list[str]]:
402
+ piece_tokens = _tokenize_shell_command(shell_command_piece)
403
+ if piece_tokens is None:
404
+ return _fallback_segments_by_physical_line(shell_command_piece)
405
+ return _split_shell_command_segments(piece_tokens)
406
+
407
+
408
+ def _split_on_unquoted_newlines(shell_command: str) -> list[str]:
409
+ all_pieces: list[str] = []
410
+ current_characters: list[str] = []
411
+ active_quote_character = ""
412
+ for each_character in shell_command:
413
+ if active_quote_character:
414
+ if each_character == active_quote_character:
415
+ active_quote_character = ""
416
+ current_characters.append(each_character)
417
+ continue
418
+ if each_character in ALL_SHELL_QUOTE_CHARACTERS:
419
+ active_quote_character = each_character
420
+ current_characters.append(each_character)
421
+ continue
422
+ if each_character in ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS:
423
+ all_pieces.append("".join(current_characters))
424
+ current_characters = []
425
+ continue
426
+ current_characters.append(each_character)
427
+ all_pieces.append("".join(current_characters))
428
+ return all_pieces
429
+
430
+
431
+ def _all_command_segments(shell_command: str) -> list[list[str]]:
432
+ collapsed_command = LINE_CONTINUATION_PATTERN.sub("", shell_command)
433
+ collapsed_command = POWERSHELL_LINE_CONTINUATION_PATTERN.sub("", collapsed_command)
434
+ all_segments: list[list[str]] = []
435
+ for each_piece in _split_on_unquoted_newlines(collapsed_command):
436
+ all_segments.extend(_segments_for_piece(each_piece))
437
+ return all_segments
438
+
439
+
440
+ def _token_is_leading_skip_target(token_text: str) -> bool:
441
+ return _token_is_git_binary(token_text) or _token_is_shell_interpreter(token_text)
442
+
443
+
444
+ def _token_is_wrapper_option_flag(token_text: str) -> bool:
445
+ return token_text.startswith(SINGLE_DASH_OPTION_PREFIX)
446
+
447
+
448
+ def _wrapper_leading_operand_count(token_text: str) -> int:
449
+ if _token_basename_lower(token_text) in ALL_ONE_OPERAND_WRAPPER_TOKENS:
450
+ return 1
451
+ return 0
452
+
453
+
454
+ def _flag_value_token_follows(all_segment_tokens: list[str], value_index: int) -> bool:
455
+ if value_index >= len(all_segment_tokens):
456
+ return False
457
+ if not _token_is_leading_skip_target(all_segment_tokens[value_index]):
458
+ return True
459
+ return _later_token_is_leading_skip_target(all_segment_tokens, value_index + 1)
460
+
461
+
462
+ def _later_token_is_leading_skip_target(
463
+ all_segment_tokens: list[str], search_start_index: int
464
+ ) -> bool:
465
+ for each_token in all_segment_tokens[search_start_index:]:
466
+ if _token_is_leading_skip_target(each_token):
467
+ return True
468
+ return False
469
+
470
+
471
+ def _skip_leading_noop_tokens(all_segment_tokens: list[str]) -> int:
472
+ token_index = 0
473
+ has_skipped_wrapper_prefix = False
474
+ pending_operand_budget = 0
475
+ while token_index < len(all_segment_tokens):
476
+ each_token = all_segment_tokens[token_index]
477
+ if _token_is_subshell_group_open(each_token):
478
+ token_index += 1
479
+ continue
480
+ if _token_is_skippable_prefix(each_token):
481
+ has_skipped_wrapper_prefix = True
482
+ pending_operand_budget += _wrapper_leading_operand_count(each_token)
483
+ token_index += 1
484
+ continue
485
+ if not has_skipped_wrapper_prefix:
486
+ break
487
+ if _token_is_leading_skip_target(each_token):
488
+ break
489
+ if _token_is_wrapper_option_flag(each_token):
490
+ token_index += 1
491
+ if _flag_value_token_follows(all_segment_tokens, token_index):
492
+ token_index += 1
493
+ continue
494
+ if pending_operand_budget > 0:
495
+ pending_operand_budget -= 1
496
+ token_index += 1
497
+ continue
498
+ break
499
+ return token_index
500
+
501
+
502
+ def _token_is_powershell_command_flag_prefix(lowered_token: str) -> bool:
503
+ if lowered_token == SINGLE_DASH_OPTION_PREFIX:
504
+ return False
505
+ return POWERSHELL_INLINE_COMMAND_FLAG.startswith(lowered_token)
506
+
507
+
508
+ def _token_is_interpreter_inline_command_flag(
509
+ token_text: str, interpreter_allows_short_flag_cluster: bool
510
+ ) -> bool:
511
+ if not token_text.startswith(SINGLE_DASH_OPTION_PREFIX):
512
+ return False
513
+ if token_text.startswith(DOUBLE_DASH_OPTION_PREFIX):
514
+ return False
515
+ lowered_token = token_text.lower()
516
+ if lowered_token == SHELL_INLINE_COMMAND_FLAG:
517
+ return True
518
+ if _token_is_powershell_command_flag_prefix(lowered_token):
519
+ return True
520
+ if not interpreter_allows_short_flag_cluster:
521
+ return False
522
+ clustered_flag_characters = lowered_token[len(SINGLE_DASH_OPTION_PREFIX) :]
523
+ return INLINE_COMMAND_FLAG_CLUSTER_CHARACTER in clustered_flag_characters
524
+
525
+
526
+ def _interpreter_inline_command_invokes_commit(
527
+ interpreter_token: str, all_following_tokens: list[str]
528
+ ) -> bool:
529
+ allows_short_flag_cluster = _token_is_bash_family_interpreter(interpreter_token)
530
+ token_index = 0
531
+ while token_index < len(all_following_tokens):
532
+ each_token = all_following_tokens[token_index]
533
+ if _token_is_interpreter_inline_command_flag(
534
+ each_token, allows_short_flag_cluster
535
+ ):
536
+ argument_index = token_index + 1
537
+ if argument_index >= len(all_following_tokens):
538
+ return False
539
+ inline_command = INLINE_COMMAND_TOKEN_JOINER.join(
540
+ all_following_tokens[argument_index:]
541
+ )
542
+ return is_git_commit_shell_command(inline_command)
543
+ token_index += 1
544
+ return False
545
+
546
+
547
+ def _segment_invokes_git_commit(all_segment_tokens: list[str]) -> bool:
548
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
549
+ if command_index >= len(all_segment_tokens):
550
+ return False
551
+ all_following_tokens = all_segment_tokens[command_index + 1 :]
552
+ command_token = all_segment_tokens[command_index]
553
+ if _token_is_shell_interpreter(command_token):
554
+ return _interpreter_inline_command_invokes_commit(
555
+ command_token, all_following_tokens
556
+ )
557
+ if not _token_is_git_binary(command_token):
558
+ return False
559
+ return _following_tokens_invoke_commit(all_following_tokens)
560
+
561
+
562
+ def is_git_commit_shell_command(shell_command: str) -> bool:
563
+ """Report whether *shell_command* invokes git commit (token-aware).
564
+
565
+ Each segment is read past its leading noise to the real command word::
566
+
567
+ sudo git commit -m x -> skip the wrapper, then match commit
568
+ nice -n 10 git commit -> skip the wrapper and its flag value
569
+ then git commit -m x -> skip the keyword, then match commit
570
+ build & git commit -m x -> split on the background operator
571
+ bash -cx "git commit" -> unwrap the bash cluster
572
+ pwsh -ExecutionPolicy Bypass -Command ... -> skip past the pwsh flag
573
+ (git commit -m x) -> step over the group open
574
+
575
+ Skipped leading tokens: a subshell-group open ``(``, env-assignments, shell
576
+ keywords (then, do, else, elif), and wrapper commands (sudo, env, time,
577
+ nice, xargs, command, stdbuf) together with each wrapper's own option flags,
578
+ flag values, and the single leading operand that timeout and flock take
579
+ before their command. A non-wrapper command word between a wrapper and a
580
+ later git commit stops the scan, so the wrapper's own payload command is
581
+ read rather than the trailing git token. Segments split on unquoted control
582
+ separators (including
583
+ a lone ``&`` background operator) and newlines, and the git binary may be
584
+ path-prefixed and carry global flags (no-verify, config, and
585
+ working-directory) before its subcommand. A shell interpreter is unwrapped
586
+ at its inline-command flag by interpreter family: bash and sh take an
587
+ isolated ``-c`` or any short-flag cluster carrying ``c`` (``-lc``, ``-cx``),
588
+ and PowerShell (pwsh, powershell) takes only ``-Command`` or ``-c``, so a
589
+ leading pwsh flag whose name merely contains ``c`` (``-ExecutionPolicy``,
590
+ ``-NonInteractive``) is stepped over until the real ``-Command`` is reached.
591
+ The inline command's remaining tokens rejoin into one string, so an
592
+ unquoted multi-token command is read whole.
593
+
594
+ Args:
595
+ shell_command: Bash or PowerShell tool command string.
596
+
597
+ Returns:
598
+ True when a command segment invokes git with a commit subcommand.
599
+ """
600
+ if not shell_command or not shell_command.strip():
601
+ return False
602
+ for each_segment in _all_command_segments(shell_command):
603
+ if _segment_invokes_git_commit(each_segment):
604
+ return True
605
+ return False
606
+
607
+
608
+ def _following_tokens_working_directory(all_following_tokens: list[str]) -> str | None:
609
+ token_index = 0
610
+ option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
611
+ while token_index < len(all_following_tokens):
612
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
613
+ if each_token == GIT_WORKING_DIRECTORY_OPTION:
614
+ value_index = token_index + 1
615
+ if value_index >= len(all_following_tokens):
616
+ return None
617
+ return _strip_token_edge_quotes(all_following_tokens[value_index])
618
+ option_name = each_token
619
+ has_attached_value = False
620
+ if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
621
+ option_name, _, _attached_value = each_token.partition("=")
622
+ has_attached_value = True
623
+ if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
624
+ token_index += 1 if has_attached_value else option_with_value_step
625
+ continue
626
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
627
+ token_index += 1
628
+ continue
629
+ return None
630
+ return None
631
+
632
+
633
+ def _segment_git_commit_working_directory(
634
+ all_segment_tokens: list[str],
635
+ ) -> str | None:
636
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
637
+ if command_index >= len(all_segment_tokens):
638
+ return None
639
+ command_token = all_segment_tokens[command_index]
640
+ if not _token_is_git_binary(command_token):
641
+ return None
642
+ all_following_tokens = all_segment_tokens[command_index + 1 :]
643
+ return _following_tokens_working_directory(all_following_tokens)
644
+
645
+
646
+ def extract_git_commit_working_directory(shell_command: str) -> str | None:
647
+ """Return the directory a git-commit command runs in, or None for the CWD.
648
+
649
+ Reads the ``-C`` value off the same commit-invoking segment the detector
650
+ matches, so every git-binary shape the detector recognizes resolves too::
651
+
652
+ git.exe -C /repoA commit -m x -> /repoA
653
+ git -C "C:/repo" commit -m x -> C:/repo
654
+ cd /repoB && git commit -m x -> /repoB (cd/pushd fallback)
655
+ git commit -m x -> None (runs in the CWD)
656
+
657
+ Args:
658
+ shell_command: Bash or PowerShell tool command string.
659
+
660
+ Returns:
661
+ The working directory the commit targets, or None when it uses the CWD.
662
+ """
663
+ for each_segment in _all_command_segments(shell_command):
664
+ if not _segment_invokes_git_commit(each_segment):
665
+ continue
666
+ segment_directory = _segment_git_commit_working_directory(each_segment)
667
+ if segment_directory is not None:
668
+ return segment_directory
669
+ return extract_git_working_directory(shell_command)
670
+
671
+
672
+ def evaluate_bash_command(
673
+ bash_command: str, working_directory: str | None
674
+ ) -> str | None:
675
+ """Return a deny reason for a shell gh post or git commit with PII.
676
+
677
+ Args:
678
+ bash_command: The Bash or PowerShell tool command string.
679
+ working_directory: Directory git should run in, or None for process CWD.
680
+
681
+ Returns:
682
+ Deny reason text, or None when the command is clean or out of scope.
683
+ """
684
+ all_post_bodies, body_file_failure_reason = (
685
+ extract_gh_post_body_texts_for_privacy_gate(
686
+ bash_command, working_directory=working_directory
687
+ )
688
+ )
689
+ if body_file_failure_reason is not None:
690
+ return body_file_failure_reason
691
+ post_deny_reason = evaluate_post_body_texts(all_post_bodies)
692
+ if post_deny_reason is not None:
693
+ return post_deny_reason
694
+ if not is_git_commit_shell_command(bash_command):
695
+ return None
696
+ command_directory = extract_git_commit_working_directory(bash_command)
697
+ resolved_directory = resolve_directory(command_directory) or working_directory
698
+ repository_root = resolve_repository_root(resolved_directory)
699
+ if repository_root is None:
700
+ return REPOSITORY_ROOT_UNRESOLVED_REASON
701
+ return evaluate_staged_commit(repository_root)
702
+
703
+
704
+ def evaluate(payload_by_key: dict[str, object]) -> str | None:
705
+ """Decide whether a PreToolUse payload carries high-confidence PII.
706
+
707
+ Args:
708
+ payload_by_key: The PreToolUse payload with tool_name and tool_input.
709
+
710
+ Returns:
711
+ Deny-reason text when blocked, or None when allowed.
712
+ """
713
+ raw_tool_name = payload_by_key.get("tool_name", "")
714
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
715
+ raw_tool_input = payload_by_key.get("tool_input", {})
716
+ all_tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
717
+
718
+ if tool_name in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
719
+ return evaluate_write_edit_payload(tool_name, all_tool_input)
720
+
721
+ if tool_name in ALL_SHELL_TOOL_NAMES:
722
+ command_value = all_tool_input.get("command", "")
723
+ if not isinstance(command_value, str) or not command_value:
724
+ return None
725
+ working_directory_value = all_tool_input.get("working_directory")
726
+ working_directory = (
727
+ working_directory_value
728
+ if isinstance(working_directory_value, str)
729
+ else None
730
+ )
731
+ if working_directory is None:
732
+ cwd_value = payload_by_key.get("cwd")
733
+ working_directory = cwd_value if isinstance(cwd_value, str) else None
734
+ return evaluate_bash_command(
735
+ command_value, working_directory=working_directory
736
+ )
737
+
738
+ if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
739
+ return evaluate_post_body_texts(extract_mcp_body_texts(all_tool_input))
740
+
741
+ return None
742
+
743
+
744
+ def build_deny_payload(deny_reason: str) -> dict[str, object]:
745
+ """Build the PreToolUse deny payload for *deny_reason*.
746
+
747
+ Args:
748
+ deny_reason: The permissionDecisionReason text.
749
+
750
+ Returns:
751
+ Deny payload dictionary serialized to stdout by the hook.
752
+ """
753
+ log_hook_block(
754
+ calling_hook_name=HOOK_SCRIPT_BASENAME,
755
+ hook_event="PreToolUse",
756
+ block_reason=deny_reason,
757
+ )
758
+ return {
759
+ "hookSpecificOutput": {
760
+ "hookEventName": "PreToolUse",
761
+ "permissionDecision": "deny",
762
+ "permissionDecisionReason": deny_reason,
763
+ }
764
+ }
765
+
766
+
767
+ def main() -> None:
768
+ """Read PreToolUse stdin and deny when high-confidence PII is present."""
769
+ payload_dictionary = read_hook_input_dictionary_from_stdin()
770
+ if payload_dictionary is None:
771
+ sys.exit(0)
772
+ deny_reason = evaluate(payload_dictionary)
773
+ if deny_reason is None:
774
+ sys.exit(0)
775
+ print(json.dumps(build_deny_payload(deny_reason)))
776
+ sys.stdout.flush()
777
+ sys.exit(0)
778
+
779
+
780
+ if __name__ == "__main__":
781
+ main()