claude-dev-env 1.93.0 → 1.94.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 (220) hide show
  1. package/_shared/pr-loop/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/audit-contract.md +1 -1
  3. package/_shared/pr-loop/gh-payloads.md +3 -3
  4. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  5. package/_shared/pr-loop/scripts/README.md +2 -2
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
  7. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  9. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
  10. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  11. package/_shared/pr-loop/state-schema.md +3 -22
  12. package/bin/CLAUDE.md +1 -1
  13. package/bin/install.mjs +2 -7
  14. package/bin/install.test.mjs +10 -4
  15. package/hooks/blocking/CLAUDE.md +2 -0
  16. package/hooks/blocking/pii_commit_command.py +398 -0
  17. package/hooks/blocking/pii_payload_scan.py +133 -0
  18. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  19. package/hooks/blocking/pii_scanner.py +26 -1
  20. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  21. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  22. package/hooks/blocking/test_pii_scanner.py +27 -2
  23. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  24. package/hooks/hooks_constants/CLAUDE.md +2 -2
  25. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  26. package/hooks/hooks_constants/local_identity.py +65 -15
  27. package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
  28. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  29. package/hooks/hooks_constants/test_local_identity.py +68 -0
  30. package/package.json +1 -1
  31. package/scripts/CLAUDE.md +10 -1
  32. package/scripts/Get-SessionAccount.ps1 +210 -0
  33. package/scripts/tests/CLAUDE.md +15 -1
  34. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  35. package/skills/CLAUDE.md +3 -25
  36. package/skills/anthropic-plan/SKILL.md +1 -13
  37. package/skills/autoconverge/CLAUDE.md +5 -2
  38. package/skills/autoconverge/SKILL.md +112 -387
  39. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  40. package/skills/autoconverge/reference/closing-report.md +9 -2
  41. package/skills/autoconverge/reference/convergence.md +26 -22
  42. package/skills/autoconverge/reference/headless-safety.md +44 -0
  43. package/skills/autoconverge/reference/multi-pr.md +88 -0
  44. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  45. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  46. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  47. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  49. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  50. package/skills/autoconverge/workflow/converge.mjs +284 -94
  51. package/skills/bugteam/CLAUDE.md +1 -1
  52. package/skills/bugteam/CONSTRAINTS.md +3 -3
  53. package/skills/bugteam/PROMPTS.md +8 -19
  54. package/skills/bugteam/SKILL.md +13 -14
  55. package/skills/bugteam/reference/CLAUDE.md +1 -1
  56. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  57. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  58. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  59. package/skills/bugteam/reference/team-setup.md +11 -11
  60. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  61. package/skills/bugteam/scripts/README.md +20 -8
  62. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  63. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  64. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  65. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  66. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  67. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  68. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  69. package/skills/copilot-finding-triage/SKILL.md +80 -28
  70. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  71. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  72. package/skills/everything-search/SKILL.md +1 -2
  73. package/skills/grokify/SKILL.md +58 -0
  74. package/skills/grokify/templates/handoff-template.md +92 -0
  75. package/skills/orchestrator/SKILL.md +12 -8
  76. package/skills/orchestrator-refresh/SKILL.md +3 -3
  77. package/skills/pr-converge/SKILL.md +132 -233
  78. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  79. package/skills/pr-converge/reference/examples.md +37 -25
  80. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  81. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  82. package/skills/pr-converge/reference/per-tick.md +64 -41
  83. package/skills/pr-converge/reference/state-schema.md +27 -2
  84. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  85. package/skills/pr-converge/scripts/README.md +4 -3
  86. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  87. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  88. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  89. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  90. package/skills/pr-converge/scripts/conftest.py +8 -0
  91. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  92. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  93. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  94. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  95. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  96. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  97. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  98. package/skills/privacy-hygiene/SKILL.md +8 -7
  99. package/skills/reviewer-gates/SKILL.md +26 -8
  100. package/skills/session-log/SKILL.md +11 -12
  101. package/skills/session-tidy/SKILL.md +40 -30
  102. package/skills/skill-builder/CLAUDE.md +13 -10
  103. package/skills/skill-builder/SKILL.md +31 -31
  104. package/skills/skill-builder/references/CLAUDE.md +10 -6
  105. package/skills/skill-builder/references/delegation-map.md +13 -4
  106. package/skills/skill-builder/references/description-field.md +113 -0
  107. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  108. package/skills/skill-builder/references/skill-modularity.md +113 -0
  109. package/skills/skill-builder/references/skill-types.md +4 -0
  110. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  111. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  112. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  113. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  114. package/skills/skill-builder/workflows/new-skill.md +34 -10
  115. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  116. package/skills/usage-pause/SKILL.md +10 -14
  117. package/skills/bdd-protocol/CLAUDE.md +0 -26
  118. package/skills/bdd-protocol/SKILL.md +0 -30
  119. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  120. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  121. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  122. package/skills/bg-agent/CLAUDE.md +0 -17
  123. package/skills/bg-agent/SKILL.md +0 -69
  124. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  125. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  127. package/skills/caveman/CLAUDE.md +0 -15
  128. package/skills/caveman/SKILL.md +0 -38
  129. package/skills/code/CLAUDE.md +0 -17
  130. package/skills/code/SKILL.md +0 -178
  131. package/skills/deep-research/CLAUDE.md +0 -17
  132. package/skills/deep-research/SKILL.md +0 -80
  133. package/skills/findbugs/CLAUDE.md +0 -20
  134. package/skills/findbugs/SKILL.md +0 -241
  135. package/skills/fixbugs/CLAUDE.md +0 -19
  136. package/skills/fixbugs/SKILL.md +0 -142
  137. package/skills/gh-paginate/CLAUDE.md +0 -18
  138. package/skills/gh-paginate/SKILL.md +0 -84
  139. package/skills/gotcha/CLAUDE.md +0 -33
  140. package/skills/gotcha/SKILL.md +0 -73
  141. package/skills/implement/CLAUDE.md +0 -27
  142. package/skills/implement/SKILL.md +0 -66
  143. package/skills/implement/scripts/CLAUDE.md +0 -22
  144. package/skills/implement/scripts/append_note.py +0 -133
  145. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  146. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  147. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  148. package/skills/implement/scripts/test_append_note.py +0 -191
  149. package/skills/log-audit/CLAUDE.md +0 -20
  150. package/skills/log-audit/SKILL.md +0 -66
  151. package/skills/log-audit/reference/CLAUDE.md +0 -9
  152. package/skills/log-audit/reference/charter.md +0 -52
  153. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  154. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  155. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  156. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  157. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  158. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  159. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  160. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  161. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  162. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  163. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  164. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  165. package/skills/logifix/CLAUDE.md +0 -36
  166. package/skills/logifix/SKILL.md +0 -69
  167. package/skills/logifix/scripts/CLAUDE.md +0 -16
  168. package/skills/logifix/scripts/logifix.ps1 +0 -205
  169. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  170. package/skills/monitor-open-prs/SKILL.md +0 -88
  171. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  172. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  173. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  174. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  175. package/skills/post-audit-findings/SKILL.md +0 -80
  176. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  177. package/skills/pr-consistency-audit/SKILL.md +0 -116
  178. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  179. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  180. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  181. package/skills/pr-fix-protocol/SKILL.md +0 -65
  182. package/skills/pr-review-responder/CLAUDE.md +0 -35
  183. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  184. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  185. package/skills/pr-review-responder/README.md +0 -168
  186. package/skills/pr-review-responder/SKILL.md +0 -203
  187. package/skills/pr-review-responder/TESTING.md +0 -350
  188. package/skills/pr-scope-resolve/SKILL.md +0 -45
  189. package/skills/pre-compact/CLAUDE.md +0 -24
  190. package/skills/pre-compact/SKILL.md +0 -134
  191. package/skills/qbug/CLAUDE.md +0 -40
  192. package/skills/qbug/SKILL.md +0 -387
  193. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  194. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  195. package/skills/refine/CLAUDE.md +0 -44
  196. package/skills/refine/SKILL.md +0 -262
  197. package/skills/refine/templates/CLAUDE.md +0 -17
  198. package/skills/refine/templates/implementation-notes-template.html +0 -56
  199. package/skills/refine/templates/plan-template.md +0 -60
  200. package/skills/research-mode/CLAUDE.md +0 -35
  201. package/skills/research-mode/SKILL.md +0 -53
  202. package/skills/structure-prompt/CLAUDE.md +0 -42
  203. package/skills/structure-prompt/SKILL.md +0 -44
  204. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  205. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  206. package/skills/structure-prompt/reference/block-classification.md +0 -27
  207. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  208. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  209. package/skills/structure-prompt/reference/cleanup.md +0 -33
  210. package/skills/structure-prompt/reference/constraints.md +0 -33
  211. package/skills/structure-prompt/reference/directives.md +0 -37
  212. package/skills/structure-prompt/reference/examples.md +0 -72
  213. package/skills/structure-prompt/reference/instantiation.md +0 -51
  214. package/skills/structure-prompt/reference/output-contract.md +0 -72
  215. package/skills/structure-prompt/reference/per-category.md +0 -23
  216. package/skills/structure-prompt/reference/persona.md +0 -38
  217. package/skills/structure-prompt/reference/research.md +0 -33
  218. package/skills/structure-prompt/reference/structure.md +0 -28
  219. package/skills/verified-build/CLAUDE.md +0 -33
  220. package/skills/verified-build/SKILL.md +0 -33
@@ -10,18 +10,22 @@ Surfaces guarded:
10
10
  staged file contents about to become history. Commit message bodies are out
11
11
  of scope; only staged blob text is scanned.
12
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``.
13
+ Payload and post-body scanning live in ``pii_payload_scan``; git-commit command
14
+ detection lives in ``pii_commit_command``; both re-export through this entry
15
+ module so the hooks.json wiring and every importer resolve unchanged.
16
+
17
+ A repository named in ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories``
18
+ list in ``~/.claude/local-identity.json`` skips the staged-commit scan; a
19
+ repository without a readable origin remote is never exempt.
15
20
  """
16
21
 
17
22
  from __future__ import annotations
18
23
 
19
24
  import json
20
- import re
21
- import shlex
22
25
  import subprocess
23
26
  import sys
24
27
  from pathlib import Path
28
+ from urllib.parse import urlparse
25
29
 
26
30
  _blocking_directory = str(Path(__file__).resolve().parent)
27
31
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
@@ -30,60 +34,44 @@ if _blocking_directory not in sys.path:
30
34
  if _hooks_directory not in sys.path:
31
35
  sys.path.insert(0, _hooks_directory)
32
36
 
33
- from block_main_commit import ( # noqa: E402
34
- extract_git_working_directory,
35
- resolve_directory,
37
+ from block_main_commit import resolve_directory # noqa: E402
38
+ from pii_commit_command import ( # noqa: E402
39
+ extract_git_commit_working_directory,
40
+ is_git_commit_shell_command,
36
41
  )
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,
42
+ from pii_payload_scan import ( # noqa: E402
43
+ build_deny_reason,
44
+ evaluate_post_body_texts,
45
+ evaluate_write_edit_payload,
40
46
  )
47
+ from pii_scanner import is_path_exempt_from_pii_scan, scan_text_for_pii # noqa: E402
48
+ from precommit_code_rules_gate import resolve_repository_root # noqa: E402
41
49
  from volatile_path_in_post_blocker import ( # noqa: E402
42
50
  extract_gh_post_body_texts_for_privacy_gate,
43
51
  extract_mcp_body_texts,
44
52
  )
45
53
 
46
54
  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
55
+ from hooks_constants.local_identity import pii_exempt_repository_slugs # noqa: E402
48
56
  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_GIT_ORIGIN_URL_COMMAND,
58
+ ALL_NETWORK_GIT_URL_SCHEMES,
57
59
  ALL_SHELL_TOOL_NAMES,
58
60
  ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
59
61
  ALL_STAGED_FILES_COMMAND,
60
- ALL_VALUE_TAKING_GIT_OPTIONS,
61
62
  ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
62
63
  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
64
  GIT_COMMAND_TIMEOUT_SECONDS,
70
- GIT_COMMIT_SUBCOMMAND,
71
- GIT_OPTION_WITH_VALUE_STEP,
72
- GIT_WORKING_DIRECTORY_OPTION,
65
+ GIT_URL_SUFFIX,
66
+ GITHUB_COM_HOST,
73
67
  HOOK_SCRIPT_BASENAME,
74
- INLINE_COMMAND_FLAG_CLUSTER_CHARACTER,
75
- INLINE_COMMAND_TOKEN_JOINER,
76
- LINE_CONTINUATION_PATTERN,
77
68
  MAXIMUM_STAGED_FILE_BYTES,
78
69
  MCP_GITHUB_TOOL_PREFIX,
79
- MESSAGE_LINE_SEPARATOR,
80
- MULTI_EDIT_TOOL_NAME,
70
+ MINIMUM_OWNER_REPO_SEGMENT_COUNT,
81
71
  NULL_BYTE_MARKER,
82
- POWERSHELL_INLINE_COMMAND_FLAG,
83
- POWERSHELL_LINE_CONTINUATION_PATTERN,
72
+ POSIX_PATH_SEPARATOR,
84
73
  REPOSITORY_ROOT_UNRESOLVED_REASON,
85
- SHELL_INLINE_COMMAND_FLAG,
86
- SINGLE_DASH_OPTION_PREFIX,
74
+ SCP_STYLE_PATH_SEPARATOR,
87
75
  STAGED_BLOB_PREFIX,
88
76
  STAGED_BLOB_REASON_DECODE_FAILED,
89
77
  STAGED_BLOB_REASON_GIT_SHOW_FAILED,
@@ -91,109 +79,15 @@ from hooks_constants.pii_prevention_constants import ( # noqa: E402
91
79
  STAGED_BLOB_REASON_OVERSIZED,
92
80
  STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE,
93
81
  STAGED_LIST_FAILURE_REASON,
94
- SUBSHELL_GROUP_OPEN_TOKEN,
95
- WRITE_TOOL_NAME,
82
+ URL_SCHEME_SEPARATOR,
83
+ USERINFO_HOST_SEPARATOR,
84
+ WINDOWS_PATH_SEPARATOR,
96
85
  )
97
86
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
98
87
  read_hook_input_dictionary_from_stdin,
99
88
  )
100
89
 
101
90
 
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
91
  def list_staged_file_paths(
198
92
  repository_root: Path,
199
93
  ) -> tuple[list[str] | None, str | None]:
@@ -314,359 +208,165 @@ def evaluate_staged_commit(
314
208
  return None
315
209
 
316
210
 
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
211
+ def _strip_trailing_path_separators(origin_url: str) -> str:
212
+ stripped_url = origin_url
213
+ while stripped_url and stripped_url[-1] in (
214
+ POSIX_PATH_SEPARATOR,
215
+ WINDOWS_PATH_SEPARATOR,
216
+ ):
217
+ stripped_url = stripped_url[:-1]
218
+ return stripped_url
340
219
 
341
220
 
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
221
+ def _strip_trailing_git_suffix(origin_url: str) -> str:
222
+ if origin_url.lower().endswith(GIT_URL_SUFFIX):
223
+ return origin_url[: -len(GIT_URL_SUFFIX)]
224
+ return origin_url
346
225
 
347
226
 
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
227
+ def _normalized_origin_url(origin_url: str) -> str:
228
+ return _strip_trailing_git_suffix(_strip_trailing_path_separators(origin_url))
369
229
 
370
230
 
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 = ""
231
+ def _host_and_path_from_scheme_url(origin_url: str) -> tuple[str, str] | None:
232
+ parsed_url = urlparse(origin_url)
233
+ if parsed_url.scheme not in ALL_NETWORK_GIT_URL_SCHEMES:
234
+ return None
235
+ maybe_host = parsed_url.hostname
236
+ if maybe_host is None:
237
+ return None
387
238
  try:
388
- return list(lexer)
239
+ parsed_url.port
389
240
  except ValueError:
390
241
  return None
242
+ repository_path = parsed_url.path.replace(
243
+ WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR
244
+ ).lstrip(POSIX_PATH_SEPARATOR)
245
+ return maybe_host.lower(), repository_path
391
246
 
392
247
 
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
-
248
+ def _host_and_path_from_scp_url(origin_url: str) -> tuple[str, str] | None:
249
+ if USERINFO_HOST_SEPARATOR not in origin_url:
250
+ return None
251
+ userinfo_and_host, separator, repository_path = origin_url.partition(
252
+ SCP_STYLE_PATH_SEPARATOR
253
+ )
254
+ if not separator or USERINFO_HOST_SEPARATOR not in userinfo_and_host:
255
+ return None
256
+ if not repository_path:
257
+ return None
258
+ host = userinfo_and_host.rsplit(USERINFO_HOST_SEPARATOR, maxsplit=1)[-1]
259
+ if not host:
260
+ return None
261
+ normalized_path = repository_path.replace(
262
+ WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR
263
+ )
264
+ return host.lower(), normalized_path
439
265
 
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
266
 
267
+ def _host_and_repository_path_from_origin_url(
268
+ origin_url: str,
269
+ ) -> tuple[str, str] | None:
270
+ normalized_url = _normalized_origin_url(origin_url)
271
+ if URL_SCHEME_SEPARATOR in normalized_url:
272
+ return _host_and_path_from_scheme_url(normalized_url)
273
+ return _host_and_path_from_scp_url(normalized_url)
443
274
 
444
- def _token_is_wrapper_option_flag(token_text: str) -> bool:
445
- return token_text.startswith(SINGLE_DASH_OPTION_PREFIX)
446
275
 
276
+ def _owner_repo_slug_from_path(repository_path: str) -> str | None:
277
+ all_segments = [
278
+ each_segment
279
+ for each_segment in repository_path.split(POSIX_PATH_SEPARATOR)
280
+ if each_segment
281
+ ]
282
+ if len(all_segments) < MINIMUM_OWNER_REPO_SEGMENT_COUNT:
283
+ return None
284
+ owner_name = all_segments[0]
285
+ repository_name = all_segments[1]
286
+ return POSIX_PATH_SEPARATOR.join((owner_name, repository_name)).lower()
447
287
 
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
288
 
289
+ def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
290
+ """Return the lowercased owner/repo slug from a github.com origin URL.
453
291
 
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
292
+ ::
500
293
 
294
+ https://github.com/Owner/Repo.git -> owner/repo
295
+ https://github.com/Owner/Repo -> owner/repo
296
+ https://github.com:443/Owner/Repo.git -> owner/repo
297
+ ssh://git@github.com:22/Owner/Repo.git -> owner/repo
298
+ git@github.com:Owner/Repo.git -> owner/repo
299
+ ssh://git@github.com/Owner/Repo.git -> owner/repo
300
+ https://github.com/Owner/Repo.git/ -> owner/repo
301
+ https://evil.test/Owner/Repo.git -> None
302
+ https://github.com:443.evil.test/Owner/Repo.git -> None
303
+ ssh://git@github.com:22.evil.test/Owner/Repo.git -> None
304
+ C:/repos/Owner/Repo -> None
501
305
 
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)
306
+ Accepts only the exact host ``github.com`` (case-insensitive). Spoof hosts,
307
+ unparseable port authorities, and path-shaped origins return None so the
308
+ repository is never exempt.
506
309
 
310
+ Args:
311
+ origin_url: The ``remote.origin.url`` value, https or ssh form.
507
312
 
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
313
+ Returns:
314
+ The ``owner/repo`` slug in lowercase, or None when the host is not
315
+ exactly ``github.com``, the port authority is unparseable, or the URL
316
+ carries no owner/repo tail.
317
+ """
318
+ maybe_host_and_path = _host_and_repository_path_from_origin_url(origin_url)
319
+ if maybe_host_and_path is None:
320
+ return None
321
+ host, repository_path = maybe_host_and_path
322
+ if host != GITHUB_COM_HOST:
323
+ return None
324
+ return _owner_repo_slug_from_path(repository_path)
545
325
 
546
326
 
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.
327
+ def _repository_origin_slug(repository_root: Path) -> str | None:
328
+ """Return the lowercased owner/repo slug of the origin remote, or None.
593
329
 
594
330
  Args:
595
- shell_command: Bash or PowerShell tool command string.
331
+ repository_root: Repository whose origin remote identifies it.
596
332
 
597
333
  Returns:
598
- True when a command segment invokes git with a commit subcommand.
334
+ The ``owner/repo`` slug in lowercase, or None when the repository has no
335
+ readable origin remote or the URL carries no owner/repo tail.
599
336
  """
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):
337
+ try:
338
+ completed_process = subprocess.run(
339
+ list(ALL_GIT_ORIGIN_URL_COMMAND),
340
+ capture_output=True,
341
+ text=True,
342
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
343
+ cwd=str(repository_root),
344
+ )
345
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
638
346
  return None
639
- command_token = all_segment_tokens[command_index]
640
- if not _token_is_git_binary(command_token):
347
+ if completed_process.returncode != 0:
641
348
  return None
642
- all_following_tokens = all_segment_tokens[command_index + 1 :]
643
- return _following_tokens_working_directory(all_following_tokens)
644
-
349
+ return _owner_repo_slug_from_origin_url(completed_process.stdout.strip())
645
350
 
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
351
 
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::
352
+ def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
353
+ """Report whether commits in *repository_root* skip the staged PII scan.
651
354
 
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)
355
+ Exempt repositories are named by owner/repo slug through
356
+ ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories`` list in the
357
+ git-ignored ``~/.claude/local-identity.json``. A repository without a readable
358
+ origin remote is never exempt (fail-closed to scanning).
656
359
 
657
360
  Args:
658
- shell_command: Bash or PowerShell tool command string.
361
+ repository_root: Repository whose index is about to be committed.
659
362
 
660
363
  Returns:
661
- The working directory the commit targets, or None when it uses the CWD.
364
+ True when the repository's origin slug is in the exempt set.
662
365
  """
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)
366
+ origin_slug = _repository_origin_slug(repository_root)
367
+ if origin_slug is None:
368
+ return False
369
+ return origin_slug in pii_exempt_repository_slugs()
670
370
 
671
371
 
672
372
  def evaluate_bash_command(
@@ -698,6 +398,8 @@ def evaluate_bash_command(
698
398
  repository_root = resolve_repository_root(resolved_directory)
699
399
  if repository_root is None:
700
400
  return REPOSITORY_ROOT_UNRESOLVED_REASON
401
+ if _is_repository_exempt_from_pii_scan(repository_root):
402
+ return None
701
403
  return evaluate_staged_commit(repository_root)
702
404
 
703
405