claude-dev-env 1.95.0 → 2.0.1

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 (241) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/test_verifier_verdict_minter.py +55 -158
  117. package/hooks/blocking/tests/conftest.py +10 -0
  118. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  119. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  120. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  121. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  122. package/hooks/git-hooks/post_commit.py +3 -4
  123. package/hooks/hooks_constants/CLAUDE.md +2 -2
  124. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  125. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  126. package/hooks/hooks_constants/local_identity.py +59 -8
  127. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  128. package/hooks/hooks_constants/test_local_identity.py +105 -3
  129. package/hooks/pyproject.toml +13 -36
  130. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  131. package/hooks/validation/mypy_validator.py +2 -2
  132. package/hooks/validators/health_check.py +1 -0
  133. package/hooks/validators/mypy_integration.py +2 -0
  134. package/hooks/validators/ruff_integration.py +3 -0
  135. package/hooks/workflow/auto_formatter.py +5 -4
  136. package/package.json +1 -1
  137. package/scripts/CLAUDE.md +4 -0
  138. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  139. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  140. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  141. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  142. package/scripts/grok_headless_runner.py +294 -0
  143. package/scripts/grok_worker_preflight.py +410 -0
  144. package/scripts/invoke_code_review.py +463 -0
  145. package/scripts/resolve_worker_spawn.py +619 -0
  146. package/scripts/spawn_grok_batch.py +672 -0
  147. package/scripts/test_grok_headless_runner.py +626 -0
  148. package/scripts/test_grok_worker_preflight.py +1054 -0
  149. package/scripts/test_invoke_code_review.py +672 -0
  150. package/scripts/test_resolve_worker_spawn.py +1014 -0
  151. package/scripts/test_spawn_grok_batch.py +1017 -0
  152. package/skills/CLAUDE.md +5 -3
  153. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  154. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  155. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  157. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  158. package/skills/autoconverge/CLAUDE.md +3 -3
  159. package/skills/autoconverge/SKILL.md +9 -3
  160. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  161. package/skills/autoconverge/reference/convergence.md +33 -11
  162. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  163. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  164. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  165. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  166. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  167. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  168. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  169. package/skills/autoconverge/workflow/converge.mjs +200 -16
  170. package/skills/bugteam/CLAUDE.md +2 -2
  171. package/skills/bugteam/CONSTRAINTS.md +3 -2
  172. package/skills/bugteam/PROMPTS.md +7 -6
  173. package/skills/bugteam/SKILL.md +18 -13
  174. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  175. package/skills/bugteam/reference/design-rationale.md +1 -1
  176. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  177. package/skills/bugteam/reference/team-setup.md +8 -2
  178. package/skills/codex-review/CLAUDE.md +46 -0
  179. package/skills/codex-review/SKILL.md +181 -0
  180. package/skills/codex-review/reference/CLAUDE.md +15 -0
  181. package/skills/codex-review/reference/cli-contract.md +253 -0
  182. package/skills/codex-review/reference/loop-integration.md +118 -0
  183. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  189. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  190. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  191. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  193. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  194. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  195. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  196. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  197. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  199. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  200. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  201. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  202. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  203. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  204. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  205. package/skills/codex-review/test_skill_scaffold.py +192 -0
  206. package/skills/grok-spawn/CLAUDE.md +28 -0
  207. package/skills/grok-spawn/SKILL.md +226 -0
  208. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  209. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  210. package/skills/grokify/SKILL.md +9 -1
  211. package/skills/grokify/capability-claims.test.mjs +28 -0
  212. package/skills/grokify/evals/README.md +72 -0
  213. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  214. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  215. package/skills/orchestrator/SKILL.md +42 -29
  216. package/skills/orchestrator-refresh/SKILL.md +17 -9
  217. package/skills/pr-converge/SKILL.md +34 -13
  218. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  219. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  220. package/skills/pr-converge/reference/ground-rules.md +1 -1
  221. package/skills/pr-converge/reference/per-tick.md +130 -42
  222. package/skills/pr-converge/reference/state-schema.md +10 -0
  223. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  224. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  225. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  226. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  227. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  228. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  229. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  230. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  231. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  232. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  234. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  235. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  237. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  238. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  239. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  240. package/skills/reviewer-gates/SKILL.md +7 -5
  241. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,214 @@
1
+ """Per-repository PII-scan exemption and allowlist keyed on origin owner/repo.
2
+
3
+ A commit's repository is exempt from the staged PII scan when its
4
+ ``remote.origin.url`` resolves to an ``owner/repo`` slug named in
5
+ ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories`` list in
6
+ ``~/.claude/local-identity.json``. A repository without a readable origin remote
7
+ is never exempt (fail-closed to scanning). Exact values listed under
8
+ ``pii_allowlisted_values`` for the same slug may pass the scan in that
9
+ repository only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+ from urllib.parse import urlparse
18
+
19
+ try:
20
+ _blocking_directory = str(Path(__file__).resolve().parent.parent)
21
+ _hooks_directory = str(Path(__file__).resolve().parent.parent.parent)
22
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
23
+ if each_bootstrap_directory not in sys.path:
24
+ sys.path.insert(0, each_bootstrap_directory)
25
+ from hooks_constants.local_identity import (
26
+ pii_allowlisted_values_by_repository,
27
+ pii_exempt_repository_slugs,
28
+ )
29
+ from hooks_constants.pii_prevention_constants import (
30
+ ALL_GIT_ORIGIN_URL_COMMAND,
31
+ ALL_NETWORK_GIT_URL_SCHEMES,
32
+ GIT_COMMAND_TIMEOUT_SECONDS,
33
+ GIT_URL_SUFFIX,
34
+ GITHUB_COM_HOST,
35
+ MINIMUM_OWNER_REPO_SEGMENT_COUNT,
36
+ POSIX_PATH_SEPARATOR,
37
+ SCP_STYLE_PATH_SEPARATOR,
38
+ URL_SCHEME_SEPARATOR,
39
+ USERINFO_HOST_SEPARATOR,
40
+ WINDOWS_PATH_SEPARATOR,
41
+ )
42
+ except ImportError as import_error:
43
+ raise ImportError(
44
+ "repository_exemption: cannot import its sibling constants; "
45
+ "ensure the blocking and hooks directories are importable."
46
+ ) from import_error
47
+
48
+
49
+ def repository_allowlisted_values(repository_root: Path) -> frozenset[str]:
50
+ """Return the exact values the repository at *repository_root* may carry.
51
+
52
+ ::
53
+
54
+ origin slug in the allowlist mapping -> that repo's frozenset of values
55
+ no origin remote, or slug not mapped -> frozenset()
56
+
57
+ Args:
58
+ repository_root: Repository whose origin owner/repo slug keys the
59
+ per-repository PII allowlist.
60
+
61
+ Returns:
62
+ The exact literal values the repository's commits and writes may carry
63
+ past the PII scan, or an empty set when the repository has none.
64
+ """
65
+ origin_slug = _repository_origin_slug(repository_root)
66
+ if origin_slug is None:
67
+ return frozenset()
68
+ return pii_allowlisted_values_by_repository().get(origin_slug, frozenset())
69
+
70
+
71
+ def _strip_trailing_path_separators(origin_url: str) -> str:
72
+ stripped_url = origin_url
73
+ while stripped_url and stripped_url[-1] in (
74
+ POSIX_PATH_SEPARATOR,
75
+ WINDOWS_PATH_SEPARATOR,
76
+ ):
77
+ stripped_url = stripped_url[:-1]
78
+ return stripped_url
79
+
80
+
81
+ def _strip_trailing_git_suffix(origin_url: str) -> str:
82
+ if origin_url.lower().endswith(GIT_URL_SUFFIX):
83
+ return origin_url[: -len(GIT_URL_SUFFIX)]
84
+ return origin_url
85
+
86
+
87
+ def _normalized_origin_url(origin_url: str) -> str:
88
+ return _strip_trailing_git_suffix(_strip_trailing_path_separators(origin_url))
89
+
90
+
91
+ def _host_and_path_from_scheme_url(origin_url: str) -> tuple[str, str] | None:
92
+ parsed_url = urlparse(origin_url)
93
+ if parsed_url.scheme not in ALL_NETWORK_GIT_URL_SCHEMES:
94
+ return None
95
+ maybe_host = parsed_url.hostname
96
+ if maybe_host is None:
97
+ return None
98
+ try:
99
+ _ = parsed_url.port
100
+ except ValueError:
101
+ return None
102
+ repository_path = parsed_url.path.replace(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR).lstrip(
103
+ POSIX_PATH_SEPARATOR
104
+ )
105
+ return maybe_host.lower(), repository_path
106
+
107
+
108
+ def _host_and_path_from_scp_url(origin_url: str) -> tuple[str, str] | None:
109
+ if USERINFO_HOST_SEPARATOR not in origin_url:
110
+ return None
111
+ userinfo_and_host, separator, repository_path = origin_url.partition(SCP_STYLE_PATH_SEPARATOR)
112
+ if not separator or USERINFO_HOST_SEPARATOR not in userinfo_and_host:
113
+ return None
114
+ if not repository_path:
115
+ return None
116
+ host = userinfo_and_host.rsplit(USERINFO_HOST_SEPARATOR, maxsplit=1)[-1]
117
+ if not host:
118
+ return None
119
+ normalized_path = repository_path.replace(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR)
120
+ return host.lower(), normalized_path
121
+
122
+
123
+ def _host_and_repository_path_from_origin_url(
124
+ origin_url: str,
125
+ ) -> tuple[str, str] | None:
126
+ normalized_url = _normalized_origin_url(origin_url)
127
+ if URL_SCHEME_SEPARATOR in normalized_url:
128
+ return _host_and_path_from_scheme_url(normalized_url)
129
+ return _host_and_path_from_scp_url(normalized_url)
130
+
131
+
132
+ def _owner_repo_slug_from_path(repository_path: str) -> str | None:
133
+ all_segments = [
134
+ each_segment for each_segment in repository_path.split(POSIX_PATH_SEPARATOR) if each_segment
135
+ ]
136
+ if len(all_segments) < MINIMUM_OWNER_REPO_SEGMENT_COUNT:
137
+ return None
138
+ owner_name = all_segments[0]
139
+ repository_name = all_segments[1]
140
+ return POSIX_PATH_SEPARATOR.join((owner_name, repository_name)).lower()
141
+
142
+
143
+ def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
144
+ """Return the lowercased owner/repo slug from a github.com origin URL.
145
+
146
+ ::
147
+
148
+ https://github.com/Owner/Repo.git -> owner/repo
149
+ https://github.com/Owner/Repo -> owner/repo
150
+ https://evil.test/Owner/Repo.git -> None
151
+
152
+ Accepts only the exact host ``github.com`` (case-insensitive), in https or
153
+ ssh form. Spoof hosts, unparseable port authorities, and path-shaped origins
154
+ return None so the repository is never exempt.
155
+
156
+ Args:
157
+ origin_url: The ``remote.origin.url`` value, https or ssh form.
158
+
159
+ Returns:
160
+ The ``owner/repo`` slug in lowercase, or None when the host is not
161
+ exactly ``github.com`` or the URL carries no owner/repo tail.
162
+ """
163
+ maybe_host_and_path = _host_and_repository_path_from_origin_url(origin_url)
164
+ if maybe_host_and_path is None:
165
+ return None
166
+ host, repository_path = maybe_host_and_path
167
+ if host != GITHUB_COM_HOST:
168
+ return None
169
+ return _owner_repo_slug_from_path(repository_path)
170
+
171
+
172
+ def _repository_origin_slug(repository_root: Path) -> str | None:
173
+ """Return the lowercased owner/repo slug of the origin remote, or None.
174
+
175
+ Args:
176
+ repository_root: Repository whose origin remote identifies it.
177
+
178
+ Returns:
179
+ The ``owner/repo`` slug in lowercase, or None when the repository has no
180
+ readable origin remote or the URL carries no owner/repo tail.
181
+ """
182
+ try:
183
+ completed_process = subprocess.run(
184
+ list(ALL_GIT_ORIGIN_URL_COMMAND),
185
+ check=False, capture_output=True,
186
+ text=True,
187
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
188
+ cwd=str(repository_root),
189
+ )
190
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
191
+ return None
192
+ if completed_process.returncode != 0:
193
+ return None
194
+ return _owner_repo_slug_from_origin_url(completed_process.stdout.strip())
195
+
196
+
197
+ def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
198
+ """Report whether commits in *repository_root* skip the staged PII scan.
199
+
200
+ Exempt repositories are named by owner/repo slug through
201
+ ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories`` list in the
202
+ git-ignored ``~/.claude/local-identity.json``. A repository without a readable
203
+ origin remote is never exempt (fail-closed to scanning).
204
+
205
+ Args:
206
+ repository_root: Repository whose index is about to be committed.
207
+
208
+ Returns:
209
+ True when the repository's origin slug is in the exempt set.
210
+ """
211
+ origin_slug = _repository_origin_slug(repository_root)
212
+ if origin_slug is None:
213
+ return False
214
+ return origin_slug in pii_exempt_repository_slugs()
@@ -0,0 +1,208 @@
1
+ """Resolve the repository a git-commit command targets, not the session cwd.
2
+
3
+ The gate must scan the repository the command names, so a commit that names a
4
+ healthy repo is scanned even when the session working directory is gone::
5
+
6
+ git -C /a -C sub commit -> /a/sub (each -C composes, like chdir)
7
+ git -C /a/b -C /c/d commit -> /c/d (a later absolute -C resets)
8
+ cd "/my repo" && git commit -> /my repo (quoted cd path, shlex-parsed)
9
+ git commit -> None (falls back to the session cwd)
10
+
11
+ The session working directory is used only when the command names no path, so a
12
+ removed or invalid cwd never blocks a commit that targets a valid repo.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ try:
22
+ _blocking_directory = str(Path(__file__).resolve().parent.parent)
23
+ _hooks_directory = str(Path(__file__).resolve().parent.parent.parent)
24
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
25
+ if each_bootstrap_directory not in sys.path:
26
+ sys.path.insert(0, each_bootstrap_directory)
27
+ from pii_commit_command import (
28
+ _all_command_segments,
29
+ _segment_invokes_git_commit,
30
+ _skip_leading_noop_tokens,
31
+ _strip_token_edge_quotes,
32
+ _token_is_git_binary,
33
+ )
34
+ from pii_prevention_blocker_parts.config.repository_resolution_constants import (
35
+ ALL_DIRECTORY_CHANGE_COMMAND_NAMES,
36
+ ALL_DIRECTORY_CHANGE_PATH_OPTION_NAMES,
37
+ DIRECTORY_CHANGE_OPTION_TERMINATOR,
38
+ REPOSITORY_ROOT_UNRESOLVED_CWD_LABEL,
39
+ REPOSITORY_ROOT_UNRESOLVED_REASON_TEMPLATE,
40
+ )
41
+
42
+ from hooks_constants.pii_prevention_constants import (
43
+ ALL_VALUE_TAKING_GIT_OPTIONS,
44
+ GIT_OPTION_WITH_VALUE_STEP,
45
+ GIT_WORKING_DIRECTORY_OPTION,
46
+ SINGLE_DASH_OPTION_PREFIX,
47
+ )
48
+ except ImportError as import_error:
49
+ raise ImportError(
50
+ "repository_resolution: cannot import its sibling helpers; "
51
+ "ensure the blocking and hooks directories are importable."
52
+ ) from import_error
53
+
54
+
55
+ def _consume_token(
56
+ all_following_tokens: list[str], token_index: int, all_values: list[str]
57
+ ) -> int | None:
58
+ """Advance past one token, appending any ``-C`` value; None ends the walk."""
59
+ each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
60
+ if each_token == GIT_WORKING_DIRECTORY_OPTION:
61
+ value_index = token_index + 1
62
+ if value_index >= len(all_following_tokens):
63
+ return None
64
+ all_values.append(_strip_token_edge_quotes(all_following_tokens[value_index]))
65
+ return value_index + 1
66
+ if each_token in ALL_VALUE_TAKING_GIT_OPTIONS:
67
+ return token_index + GIT_OPTION_WITH_VALUE_STEP
68
+ if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
69
+ return token_index + 1
70
+ return None
71
+
72
+
73
+ def _collect_working_directory_values(all_following_tokens: list[str]) -> list[str]:
74
+ """Return every ``-C`` value, in order, from a commit segment's tokens."""
75
+ all_values: list[str] = []
76
+ token_index: int | None = 0
77
+ while token_index is not None and token_index < len(all_following_tokens):
78
+ token_index = _consume_token(all_following_tokens, token_index, all_values)
79
+ return all_values
80
+
81
+
82
+ def _all_commit_working_directory_values(shell_command: str) -> list[str]:
83
+ """Return every ``-C`` value on the command's git-commit segment, in order."""
84
+ for each_segment in _all_command_segments(shell_command):
85
+ if not _segment_invokes_git_commit(each_segment):
86
+ continue
87
+ command_index = _skip_leading_noop_tokens(each_segment)
88
+ if command_index >= len(each_segment):
89
+ continue
90
+ if not _token_is_git_binary(each_segment[command_index]):
91
+ continue
92
+ return _collect_working_directory_values(each_segment[command_index + 1 :])
93
+ return []
94
+
95
+
96
+ def _directory_change_target_from_segment(
97
+ all_segment_tokens: list[str],
98
+ ) -> str | None:
99
+ """Return the destination path of a ``cd``/``pushd`` segment, or None."""
100
+ command_index = _skip_leading_noop_tokens(all_segment_tokens)
101
+ if command_index >= len(all_segment_tokens):
102
+ return None
103
+ command_token = _strip_token_edge_quotes(
104
+ all_segment_tokens[command_index]
105
+ ).lower()
106
+ if command_token not in ALL_DIRECTORY_CHANGE_COMMAND_NAMES:
107
+ return None
108
+ token_index = command_index + 1
109
+ while token_index < len(all_segment_tokens):
110
+ each_token = _strip_token_edge_quotes(all_segment_tokens[token_index])
111
+ if each_token == DIRECTORY_CHANGE_OPTION_TERMINATOR:
112
+ token_index += 1
113
+ continue
114
+ if each_token.lower() in ALL_DIRECTORY_CHANGE_PATH_OPTION_NAMES:
115
+ token_index += 1
116
+ continue
117
+ return each_token
118
+ return None
119
+
120
+
121
+ def _leading_directory_change_path(shell_command: str) -> str | None:
122
+ """Return the last ``cd``/``pushd`` path before the first git-commit segment.
123
+
124
+ Token-aware so ``git.exe`` (and other recognized git binary spellings) still
125
+ pick up a leading directory change, which the regex ``git commit`` fallback
126
+ misses.
127
+ """
128
+ last_changed_directory: str | None = None
129
+ for each_segment in _all_command_segments(shell_command):
130
+ if _segment_invokes_git_commit(each_segment):
131
+ return last_changed_directory
132
+ segment_directory = _directory_change_target_from_segment(each_segment)
133
+ if segment_directory is not None:
134
+ last_changed_directory = segment_directory
135
+ return None
136
+
137
+
138
+ def _next_composed_directory(composed_directory: str | None, each_value: str) -> str:
139
+ """Apply one ``-C`` value to the running composition, like a chained chdir."""
140
+ if os.path.isabs(each_value):
141
+ return each_value
142
+ if composed_directory is None:
143
+ return each_value
144
+ return os.path.join(composed_directory, each_value)
145
+
146
+
147
+ def _compose_working_directories(all_values: list[str]) -> str | None:
148
+ """Compose ordered ``-C`` values the way git applies them."""
149
+ composed_directory: str | None = None
150
+ for each_value in all_values:
151
+ composed_directory = _next_composed_directory(composed_directory, each_value)
152
+ return composed_directory
153
+
154
+
155
+ def compose_command_working_directory(shell_command: str) -> str | None:
156
+ """Return the directory a git-commit command targets, or None for the cwd.
157
+
158
+ ::
159
+
160
+ git -C /a -C sub commit -> /a/sub
161
+ cd "/my repo" && git commit -> /my repo
162
+ cd /repo && git.exe commit -> /repo
163
+ git commit -> None
164
+
165
+ Multiple ``-C`` values compose in order (a later absolute value resets); a
166
+ leading ``cd``/``pushd`` supplies the target when the command carries no
167
+ ``-C``.
168
+
169
+ Args:
170
+ shell_command: Bash or PowerShell tool command string.
171
+
172
+ Returns:
173
+ The composed target directory, or None when the command names none and
174
+ the commit runs in the session working directory.
175
+ """
176
+ all_values = _all_commit_working_directory_values(shell_command)
177
+ if all_values:
178
+ return _compose_working_directories(all_values)
179
+ return _leading_directory_change_path(shell_command)
180
+
181
+
182
+ def expand_user_directory(directory: str | None) -> str | None:
183
+ """Expand a leading ``~`` in *directory*, passing None through unchanged.
184
+
185
+ Args:
186
+ directory: A directory path that may carry a leading ``~``, or None.
187
+
188
+ Returns:
189
+ The tilde-expanded path, or None when *directory* is None.
190
+ """
191
+ if directory is None:
192
+ return None
193
+ return os.path.expanduser(directory)
194
+
195
+
196
+ def refusal_reason_for_unresolved_repository(attempted_directory: str | None) -> str:
197
+ """Return the deny reason naming the path whose repository root did not resolve.
198
+
199
+ Args:
200
+ attempted_directory: The directory the gate tried to resolve, or None
201
+ when it fell back to the session working directory.
202
+
203
+ Returns:
204
+ The deny reason text, naming the attempted path so the reader sees which
205
+ directory failed to resolve.
206
+ """
207
+ attempted_path = attempted_directory or REPOSITORY_ROOT_UNRESOLVED_CWD_LABEL
208
+ return REPOSITORY_ROOT_UNRESOLVED_REASON_TEMPLATE.format(attempted_path=attempted_path)
@@ -46,14 +46,18 @@ def _read_body_file_contents(file_path: str) -> str | None:
46
46
  resolved_target = given_path.resolve()
47
47
  try:
48
48
  resolved_target.relative_to(allowed_root)
49
- except ValueError:
50
- raise PathTraversalError("symlink target resolves outside allowed root")
49
+ except ValueError as traversal_error:
50
+ raise PathTraversalError(
51
+ "symlink target resolves outside allowed root"
52
+ ) from traversal_error
51
53
  resolved_path = given_path.resolve()
52
54
  if not given_path.is_absolute():
53
55
  try:
54
56
  resolved_path.relative_to(allowed_root)
55
- except ValueError:
56
- raise PathTraversalError("relative path resolves outside allowed root")
57
+ except ValueError as traversal_error:
58
+ raise PathTraversalError(
59
+ "relative path resolves outside allowed root"
60
+ ) from traversal_error
57
61
  try:
58
62
  with open(resolved_path, "r", encoding=UTF8_ENCODING, errors="replace") as body_file:
59
63
  return body_file.read()
@@ -72,7 +72,7 @@ def resolve_repository_root(working_directory: str | None) -> Path | None:
72
72
  try:
73
73
  completed_process = subprocess.run(
74
74
  list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
75
- capture_output=True,
75
+ check=False, capture_output=True,
76
76
  text=True,
77
77
  timeout=GIT_COMMAND_TIMEOUT_SECONDS,
78
78
  cwd=working_directory,
@@ -102,7 +102,7 @@ def list_staged_python_files(repository_root: Path) -> list[str]:
102
102
  try:
103
103
  completed_process = subprocess.run(
104
104
  list(ALL_STAGED_PYTHON_FILES_COMMAND),
105
- capture_output=True,
105
+ check=False, capture_output=True,
106
106
  text=True,
107
107
  timeout=GIT_COMMAND_TIMEOUT_SECONDS,
108
108
  cwd=str(repository_root),
@@ -145,7 +145,7 @@ def run_staged_gate(repository_root: Path) -> tuple[int, str]:
145
145
  str(repository_root),
146
146
  "--staged",
147
147
  ],
148
- capture_output=True,
148
+ check=False, capture_output=True,
149
149
  text=True,
150
150
  encoding="utf-8",
151
151
  errors="replace",