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.
- package/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +20 -20
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
- package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
- package/_shared/advisor/scripts/tier_model_ids.py +18 -18
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
- package/_shared/pr-loop/worker-spawn.md +186 -0
- package/agents/code-verifier.md +1 -1
- package/bin/ever-shipped-skills.mjs +3 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +5 -2
- package/hooks/advisory/refactor_guard.py +3 -4
- package/hooks/blocking/CLAUDE.md +7 -1
- package/hooks/blocking/block_main_commit.py +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
- package/hooks/blocking/code_rules_mock_completeness.py +1 -1
- package/hooks/blocking/code_rules_optional_params.py +2 -2
- package/hooks/blocking/code_rules_shared.py +1 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
- package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
- package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
- package/hooks/blocking/inventory_intent_records/records.py +271 -0
- package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
- package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
- package/hooks/blocking/pii_payload_scan.py +138 -42
- package/hooks/blocking/pii_prevention_blocker.py +185 -291
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/tdd_enforcer.py +97 -608
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
- package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
- package/hooks/blocking/test_pii_payload_scan.py +168 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +55 -158
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +13 -36
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/scripts/CLAUDE.md +4 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
- package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
- package/scripts/dev_env_scripts_constants/timing.py +7 -1
- package/scripts/grok_headless_runner.py +294 -0
- package/scripts/grok_worker_preflight.py +410 -0
- package/scripts/invoke_code_review.py +463 -0
- package/scripts/resolve_worker_spawn.py +619 -0
- package/scripts/spawn_grok_batch.py +672 -0
- package/scripts/test_grok_headless_runner.py +626 -0
- package/scripts/test_grok_worker_preflight.py +1054 -0
- package/scripts/test_invoke_code_review.py +672 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -0
- package/scripts/test_spawn_grok_batch.py +1017 -0
- package/skills/CLAUDE.md +5 -3
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +33 -11
- package/skills/autoconverge/reference/stop-conditions.md +16 -5
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +200 -16
- package/skills/bugteam/CLAUDE.md +2 -2
- package/skills/bugteam/CONSTRAINTS.md +3 -2
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +18 -13
- package/skills/bugteam/reference/audit-and-teammates.md +215 -35
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -2
- package/skills/codex-review/CLAUDE.md +46 -0
- package/skills/codex-review/SKILL.md +181 -0
- package/skills/codex-review/reference/CLAUDE.md +15 -0
- package/skills/codex-review/reference/cli-contract.md +253 -0
- package/skills/codex-review/reference/loop-integration.md +118 -0
- package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
- package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
- package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
- package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
- package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
- package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
- package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
- package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
- package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
- package/skills/codex-review/scripts/run_codex_review.py +415 -0
- package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
- package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
- package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
- package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
- package/skills/codex-review/test_skill_scaffold.py +192 -0
- package/skills/grok-spawn/CLAUDE.md +28 -0
- package/skills/grok-spawn/SKILL.md +226 -0
- package/skills/grok-spawn/reference/flag-profiles.md +132 -0
- package/skills/grok-spawn/reference/worker-briefs.md +152 -0
- package/skills/grokify/SKILL.md +9 -1
- package/skills/grokify/capability-claims.test.mjs +28 -0
- package/skills/grokify/evals/README.md +72 -0
- package/skills/grokify/evals/parse-payload.test.mjs +171 -0
- package/skills/grokify/evals/run-capability-evals.mjs +545 -0
- package/skills/orchestrator/SKILL.md +42 -29
- package/skills/orchestrator-refresh/SKILL.md +17 -9
- package/skills/pr-converge/SKILL.md +34 -13
- package/skills/pr-converge/reference/convergence-gates.md +42 -15
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +130 -42
- package/skills/pr-converge/reference/state-schema.md +10 -0
- package/skills/pr-converge/scripts/CLAUDE.md +2 -0
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
- package/skills/pr-converge/scripts/check_convergence.py +605 -29
- package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
- package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
- package/skills/pr-converge/test_step5_host_branch.py +106 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- 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(
|
|
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(
|
|
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",
|