claude-dev-env 1.95.0 → 2.0.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.
- 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/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 +15 -11
- package/skills/orchestrator-refresh/SKILL.md +5 -5
- 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,168 @@
|
|
|
1
|
+
"""Behavior tests for the Write/Edit payload PII evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
_HOOK_DIR = Path(__file__).parent
|
|
13
|
+
_HOOKS_DIR = _HOOK_DIR.parent
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from pii_payload_scan import evaluate_post_body_texts, evaluate_write_edit_payload
|
|
17
|
+
except ImportError:
|
|
18
|
+
for each_bootstrap_directory in (str(_HOOK_DIR), str(_HOOKS_DIR)):
|
|
19
|
+
if each_bootstrap_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
21
|
+
from pii_payload_scan import evaluate_post_body_texts, evaluate_write_edit_payload
|
|
22
|
+
|
|
23
|
+
_ALLOW_SLUG = "AllowOwner/allow-repo"
|
|
24
|
+
_OTHER_SLUG = "OtherOwner/other-repo"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_post_body_with_pii_is_blocked_by_default() -> None:
|
|
28
|
+
deny_reason = evaluate_post_body_texts([_assembled_fixture_email()])
|
|
29
|
+
assert deny_reason is not None
|
|
30
|
+
assert "post body" in deny_reason
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_post_body_allowlisted_values_plumb_through_to_the_scan() -> None:
|
|
34
|
+
allowed_value = _assembled_fixture_email()
|
|
35
|
+
deny_reason = evaluate_post_body_texts(
|
|
36
|
+
[allowed_value], all_allowlisted_values=frozenset({allowed_value})
|
|
37
|
+
)
|
|
38
|
+
assert deny_reason is None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_post_body_unlisted_value_still_blocked_beside_allowlist() -> None:
|
|
42
|
+
deny_reason = evaluate_post_body_texts(
|
|
43
|
+
[_assembled_unlisted_email()],
|
|
44
|
+
all_allowlisted_values=frozenset({_assembled_fixture_email()}),
|
|
45
|
+
)
|
|
46
|
+
assert deny_reason is not None
|
|
47
|
+
assert "post body" in deny_reason
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _assembled_fixture_email() -> str:
|
|
51
|
+
return "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _assembled_unlisted_email() -> str:
|
|
55
|
+
return "other.person" + "@" + "different-co" + ".example" + ".io"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_allowlist_identity(identity_path: Path, slug: str, value: str) -> None:
|
|
59
|
+
identity_path.write_text(
|
|
60
|
+
json.dumps({"pii_allowlisted_values": {slug: [value]}}),
|
|
61
|
+
encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _init_repo_with_github_origin(repository_root: Path, origin_slug: str) -> None:
|
|
66
|
+
repository_root.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
68
|
+
origin_url = "https://github.com/" + origin_slug + ".git"
|
|
69
|
+
subprocess.run(
|
|
70
|
+
["git", "remote", "add", "origin", origin_url],
|
|
71
|
+
cwd=repository_root,
|
|
72
|
+
check=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _write_deny_reason(target_path: Path, value: str) -> str | None:
|
|
77
|
+
content = "owner email " + value + "\n"
|
|
78
|
+
return evaluate_write_edit_payload(
|
|
79
|
+
"Write", {"file_path": str(target_path), "content": content}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_write_payload_with_a_fixture_email_is_flagged(tmp_path: Path) -> None:
|
|
84
|
+
deny_reason = _write_deny_reason(tmp_path / "notes.md", _assembled_fixture_email())
|
|
85
|
+
assert deny_reason is not None
|
|
86
|
+
assert "email" in deny_reason
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_allowlisted_value_under_the_allowlisted_repo_is_allowed(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
91
|
+
) -> None:
|
|
92
|
+
allowed_value = _assembled_fixture_email()
|
|
93
|
+
identity_path = tmp_path / "local-identity.json"
|
|
94
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
95
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
96
|
+
repository_root = tmp_path / "repo"
|
|
97
|
+
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
98
|
+
assert _write_deny_reason(repository_root / "notes.md", allowed_value) is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_allowlisted_value_in_a_different_repo_is_blocked(
|
|
102
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
103
|
+
) -> None:
|
|
104
|
+
allowed_value = _assembled_fixture_email()
|
|
105
|
+
identity_path = tmp_path / "local-identity.json"
|
|
106
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
107
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
108
|
+
repository_root = tmp_path / "repo"
|
|
109
|
+
_init_repo_with_github_origin(repository_root, _OTHER_SLUG)
|
|
110
|
+
deny_reason = _write_deny_reason(repository_root / "notes.md", allowed_value)
|
|
111
|
+
assert deny_reason is not None
|
|
112
|
+
assert "email" in deny_reason
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_unlisted_value_in_the_allowlisted_repo_is_blocked(
|
|
116
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
117
|
+
) -> None:
|
|
118
|
+
identity_path = tmp_path / "local-identity.json"
|
|
119
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
|
|
120
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
121
|
+
repository_root = tmp_path / "repo"
|
|
122
|
+
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
123
|
+
deny_reason = _write_deny_reason(
|
|
124
|
+
repository_root / "notes.md", _assembled_unlisted_email()
|
|
125
|
+
)
|
|
126
|
+
assert deny_reason is not None
|
|
127
|
+
assert "email" in deny_reason
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_clean_write_payload_skips_repository_resolution(
|
|
131
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
132
|
+
) -> None:
|
|
133
|
+
identity_path = tmp_path / "local-identity.json"
|
|
134
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
|
|
135
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
136
|
+
repository_root = tmp_path / "repo"
|
|
137
|
+
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
138
|
+
|
|
139
|
+
def _fail_if_called(_working_directory: str | None) -> None:
|
|
140
|
+
raise AssertionError(
|
|
141
|
+
"resolve_repository_root should not run for a payload with no PII"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr(
|
|
145
|
+
sys.modules["pii_payload_scan"], "resolve_repository_root", _fail_if_called
|
|
146
|
+
)
|
|
147
|
+
deny_reason = evaluate_write_edit_payload(
|
|
148
|
+
"Write",
|
|
149
|
+
{
|
|
150
|
+
"file_path": str(repository_root / "notes.md"),
|
|
151
|
+
"content": "nothing sensitive here\n",
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
assert deny_reason is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_allowlisted_value_under_missing_nested_parent_is_allowed(
|
|
158
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
159
|
+
) -> None:
|
|
160
|
+
allowed_value = _assembled_fixture_email()
|
|
161
|
+
identity_path = tmp_path / "local-identity.json"
|
|
162
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
163
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
164
|
+
repository_root = tmp_path / "repo"
|
|
165
|
+
_init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
|
|
166
|
+
nested_target = repository_root / "new_dir" / "deeper" / "notes.md"
|
|
167
|
+
assert not nested_target.parent.exists()
|
|
168
|
+
assert _write_deny_reason(nested_target, allowed_value) is None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""End-to-end tests for the absent-but-tracked restore exemption.
|
|
2
|
+
|
|
3
|
+
Drives the real ``tdd_enforcer.py`` entry hook over a Write payload in a temp
|
|
4
|
+
git repository, proving a remove-then-Write rewrite of committed code is allowed
|
|
5
|
+
even when the paired test's freshness window has lapsed, while new untracked
|
|
6
|
+
code and real edits to a present file stay gated.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SCRIPT_PATH = Path(__file__).parent / "tdd_enforcer.py"
|
|
16
|
+
STALE_MTIME_OFFSET_SECONDS = 700
|
|
17
|
+
DISABLE_EPHEMERAL_ENVIRONMENT = {"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
23
|
+
input=json.dumps(payload),
|
|
24
|
+
text=True,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
check=False,
|
|
27
|
+
env={**os.environ, **DISABLE_EPHEMERAL_ENVIRONMENT},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_payload(file_path: Path, content: str) -> dict:
|
|
32
|
+
return {"tool_name": "Write", "tool_input": {"file_path": str(file_path), "content": content}}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
|
|
36
|
+
if not completed.stdout:
|
|
37
|
+
return None
|
|
38
|
+
hook_output = json.loads(completed.stdout).get("hookSpecificOutput", {})
|
|
39
|
+
return hook_output.get("permissionDecision")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_git(repository_root: Path, *arguments: str) -> None:
|
|
43
|
+
subprocess.run(
|
|
44
|
+
["git", *arguments],
|
|
45
|
+
cwd=str(repository_root),
|
|
46
|
+
check=True,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
env={**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _init_git_repository(repository_root: Path) -> None:
|
|
53
|
+
_run_git(repository_root, "init", "-q")
|
|
54
|
+
_run_git(repository_root, "config", "user.email", "test@example.com")
|
|
55
|
+
_run_git(repository_root, "config", "user.name", "Test")
|
|
56
|
+
_run_git(repository_root, "config", "commit.gpgsign", "false")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _commit_tracked_service(repository_root: Path) -> tuple[Path, Path]:
|
|
60
|
+
production_file = repository_root / "service.py"
|
|
61
|
+
production_file.write_text("def serve(): return 1\n")
|
|
62
|
+
sibling_test = repository_root / "test_service.py"
|
|
63
|
+
sibling_test.write_text("def test_serve(): pass\n")
|
|
64
|
+
_run_git(repository_root, "add", "-A")
|
|
65
|
+
_run_git(repository_root, "commit", "-q", "-m", "init")
|
|
66
|
+
return production_file, sibling_test
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_should_allow_restore_write_of_absent_but_tracked_file_despite_stale_window(
|
|
70
|
+
tmp_path: Path,
|
|
71
|
+
) -> None:
|
|
72
|
+
repository_root = tmp_path / "repo"
|
|
73
|
+
repository_root.mkdir()
|
|
74
|
+
_init_git_repository(repository_root)
|
|
75
|
+
production_file, sibling_test = _commit_tracked_service(repository_root)
|
|
76
|
+
stale_timestamp = os.stat(sibling_test).st_mtime - STALE_MTIME_OFFSET_SECONDS
|
|
77
|
+
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
78
|
+
production_file.unlink()
|
|
79
|
+
|
|
80
|
+
completed = _run_hook(_write_payload(production_file, "def serve(): return 2\n"))
|
|
81
|
+
|
|
82
|
+
assert _decision_from(completed) == "allow"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_still_deny_write_of_absent_untracked_file_without_fresh_test(
|
|
86
|
+
tmp_path: Path,
|
|
87
|
+
) -> None:
|
|
88
|
+
repository_root = tmp_path / "repo"
|
|
89
|
+
repository_root.mkdir()
|
|
90
|
+
_init_git_repository(repository_root)
|
|
91
|
+
untracked_file = repository_root / "brand_new.py"
|
|
92
|
+
|
|
93
|
+
completed = _run_hook(_write_payload(untracked_file, "def fresh(): return 1\n"))
|
|
94
|
+
|
|
95
|
+
assert _decision_from(completed) == "deny"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_should_still_deny_write_of_present_file_with_stale_test(tmp_path: Path) -> None:
|
|
99
|
+
repository_root = tmp_path / "repo"
|
|
100
|
+
repository_root.mkdir()
|
|
101
|
+
_init_git_repository(repository_root)
|
|
102
|
+
production_file, sibling_test = _commit_tracked_service(repository_root)
|
|
103
|
+
stale_timestamp = os.stat(sibling_test).st_mtime - STALE_MTIME_OFFSET_SECONDS
|
|
104
|
+
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
105
|
+
|
|
106
|
+
completed = _run_hook(_write_payload(production_file, "def serve(): return 2\n"))
|
|
107
|
+
|
|
108
|
+
assert _decision_from(completed) == "deny"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Put the blocking and hooks directories on sys.path for the blocking tests."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_blocking_directory = str(Path(__file__).resolve().parents[1])
|
|
7
|
+
_hooks_directory = str(Path(__file__).resolve().parents[2])
|
|
8
|
+
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
9
|
+
if each_bootstrap_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Repository-resolution behavior of the PII commit gate (issue #65).
|
|
2
|
+
|
|
3
|
+
The gate scans the repository the command names, not the session working
|
|
4
|
+
directory, so::
|
|
5
|
+
|
|
6
|
+
git -C <healthy repo> commit -> scans <healthy repo> from any cwd
|
|
7
|
+
cd <repo> && git commit -> scans <repo>
|
|
8
|
+
git -C <repo> commit (cwd gone) -> still scans <repo>, never refuses for cwd
|
|
9
|
+
git -C <not-a-repo> commit -> refusal names <not-a-repo>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
from pii_prevention_blocker import evaluate, evaluate_bash_command
|
|
21
|
+
|
|
22
|
+
_HOOK_PATH = Path(__file__).resolve().parents[1] / "pii_prevention_blocker.py"
|
|
23
|
+
_ALLOW_SLUG = "AllowOwner/allow-repo"
|
|
24
|
+
_OTHER_SLUG = "OtherOwner/other-repo"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run_hook(payload: dict[str, object]) -> tuple[int, str]:
|
|
28
|
+
completed_process = subprocess.run(
|
|
29
|
+
[sys.executable, str(_HOOK_PATH)],
|
|
30
|
+
input=json.dumps(payload),
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=30,
|
|
34
|
+
check=False,
|
|
35
|
+
)
|
|
36
|
+
return completed_process.returncode, completed_process.stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _assembled_fixture_email() -> str:
|
|
40
|
+
return "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _assembled_unlisted_email() -> str:
|
|
44
|
+
return "other.person" + "@" + "different-co" + ".example" + ".io"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_allowlist_identity(identity_path: Path, slug: str, value: str) -> None:
|
|
48
|
+
identity_path.write_text(
|
|
49
|
+
json.dumps({"pii_allowlisted_values": {slug: [value]}}),
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _init_repo_with_origin_and_staged_value(
|
|
55
|
+
repository_root: Path, origin_slug: str, staged_value: str
|
|
56
|
+
) -> None:
|
|
57
|
+
repository_root.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
59
|
+
subprocess.run(
|
|
60
|
+
["git", "config", "user.email", "dev@example.com"],
|
|
61
|
+
cwd=repository_root,
|
|
62
|
+
check=True,
|
|
63
|
+
)
|
|
64
|
+
subprocess.run(
|
|
65
|
+
["git", "config", "user.name", "Fixture Dev"], cwd=repository_root, check=True
|
|
66
|
+
)
|
|
67
|
+
subprocess.run(
|
|
68
|
+
["git", "remote", "add", "origin", "https://github.com/" + origin_slug + ".git"],
|
|
69
|
+
cwd=repository_root,
|
|
70
|
+
check=True,
|
|
71
|
+
)
|
|
72
|
+
(repository_root / "notes.md").write_text(
|
|
73
|
+
"owner email " + staged_value + "\n", encoding="utf-8"
|
|
74
|
+
)
|
|
75
|
+
subprocess.run(["git", "add", "notes.md"], cwd=repository_root, check=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _commit_payload(repository_root: Path) -> dict[str, object]:
|
|
79
|
+
return {
|
|
80
|
+
"tool_name": "Bash",
|
|
81
|
+
"tool_input": {"command": f"git -C {repository_root} commit -m x"},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_commit_subprocess_allows_allowlisted_value_in_the_allowlisted_repo(
|
|
86
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
87
|
+
) -> None:
|
|
88
|
+
allowed_value = _assembled_fixture_email()
|
|
89
|
+
identity_path = tmp_path / "local-identity.json"
|
|
90
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
91
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
92
|
+
repository_root = tmp_path / "repo"
|
|
93
|
+
_init_repo_with_origin_and_staged_value(repository_root, _ALLOW_SLUG, allowed_value)
|
|
94
|
+
return_code, hook_stdout = _run_hook(_commit_payload(repository_root))
|
|
95
|
+
assert return_code == 0
|
|
96
|
+
assert hook_stdout.strip() == ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_commit_subprocess_blocks_allowlisted_value_in_a_different_repo(
|
|
100
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
101
|
+
) -> None:
|
|
102
|
+
allowed_value = _assembled_fixture_email()
|
|
103
|
+
identity_path = tmp_path / "local-identity.json"
|
|
104
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, allowed_value)
|
|
105
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
106
|
+
repository_root = tmp_path / "repo"
|
|
107
|
+
_init_repo_with_origin_and_staged_value(repository_root, _OTHER_SLUG, allowed_value)
|
|
108
|
+
_return_code, hook_stdout = _run_hook(_commit_payload(repository_root))
|
|
109
|
+
assert "email" in hook_stdout
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_commit_blocks_an_unlisted_value_in_the_allowlisted_repo(
|
|
113
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
114
|
+
) -> None:
|
|
115
|
+
identity_path = tmp_path / "local-identity.json"
|
|
116
|
+
_write_allowlist_identity(identity_path, _ALLOW_SLUG, _assembled_fixture_email())
|
|
117
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
118
|
+
repository_root = tmp_path / "repo"
|
|
119
|
+
_init_repo_with_origin_and_staged_value(
|
|
120
|
+
repository_root, _ALLOW_SLUG, _assembled_unlisted_email()
|
|
121
|
+
)
|
|
122
|
+
deny_reason = evaluate_bash_command(
|
|
123
|
+
f"git -C {repository_root} commit -m x",
|
|
124
|
+
working_directory=str(repository_root),
|
|
125
|
+
)
|
|
126
|
+
assert deny_reason is not None
|
|
127
|
+
assert "email" in deny_reason
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_commit_with_missing_local_identity_still_blocks_the_value(
|
|
131
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
132
|
+
) -> None:
|
|
133
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(tmp_path / "absent.json"))
|
|
134
|
+
repository_root = tmp_path / "repo"
|
|
135
|
+
_init_repo_with_origin_and_staged_value(
|
|
136
|
+
repository_root, _ALLOW_SLUG, _assembled_fixture_email()
|
|
137
|
+
)
|
|
138
|
+
deny_reason = evaluate_bash_command(
|
|
139
|
+
f"git -C {repository_root} commit -m x",
|
|
140
|
+
working_directory=str(repository_root),
|
|
141
|
+
)
|
|
142
|
+
assert deny_reason is not None
|
|
143
|
+
assert "email" in deny_reason
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _init_repo_with_staged_pii(repository_root: Path) -> None:
|
|
147
|
+
repository_root.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
149
|
+
subprocess.run(
|
|
150
|
+
["git", "config", "user.email", "dev@example.com"],
|
|
151
|
+
cwd=repository_root,
|
|
152
|
+
check=True,
|
|
153
|
+
)
|
|
154
|
+
subprocess.run(
|
|
155
|
+
["git", "config", "user.name", "Fixture Dev"],
|
|
156
|
+
cwd=repository_root,
|
|
157
|
+
check=True,
|
|
158
|
+
)
|
|
159
|
+
(repository_root / "notes.md").write_text(
|
|
160
|
+
"owner email " + _assembled_fixture_email() + "\n",
|
|
161
|
+
encoding="utf-8",
|
|
162
|
+
)
|
|
163
|
+
subprocess.run(["git", "add", "notes.md"], cwd=repository_root, check=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_dash_c_scans_named_repo_from_unrelated_cwd(tmp_path: Path) -> None:
|
|
167
|
+
repository_root = tmp_path / "healthy_repo"
|
|
168
|
+
_init_repo_with_staged_pii(repository_root)
|
|
169
|
+
deny_reason = evaluate_bash_command(
|
|
170
|
+
f"git -C {repository_root} commit -m x",
|
|
171
|
+
working_directory="/tmp",
|
|
172
|
+
)
|
|
173
|
+
assert deny_reason is not None
|
|
174
|
+
assert "email" in deny_reason
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_cd_prefix_scans_named_repo_from_unrelated_cwd(tmp_path: Path) -> None:
|
|
178
|
+
repository_root = tmp_path / "healthy_repo"
|
|
179
|
+
_init_repo_with_staged_pii(repository_root)
|
|
180
|
+
deny_reason = evaluate_bash_command(
|
|
181
|
+
f"cd {repository_root} && git commit -m x",
|
|
182
|
+
working_directory="/tmp",
|
|
183
|
+
)
|
|
184
|
+
assert deny_reason is not None
|
|
185
|
+
assert "email" in deny_reason
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_cd_prefix_with_git_exe_scans_named_repo_from_unrelated_cwd(
|
|
189
|
+
tmp_path: Path,
|
|
190
|
+
) -> None:
|
|
191
|
+
repository_root = tmp_path / "healthy_repo"
|
|
192
|
+
_init_repo_with_staged_pii(repository_root)
|
|
193
|
+
deny_reason = evaluate_bash_command(
|
|
194
|
+
f"cd {repository_root} && git.exe commit -m x",
|
|
195
|
+
working_directory="/tmp",
|
|
196
|
+
)
|
|
197
|
+
assert deny_reason is not None
|
|
198
|
+
assert "email" in deny_reason
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_removed_working_directory_does_not_block_named_repo(tmp_path: Path) -> None:
|
|
202
|
+
repository_root = tmp_path / "healthy_repo"
|
|
203
|
+
_init_repo_with_staged_pii(repository_root)
|
|
204
|
+
removed_directory = tmp_path / "gone"
|
|
205
|
+
removed_directory.mkdir()
|
|
206
|
+
removed_directory.rmdir()
|
|
207
|
+
deny_reason = evaluate_bash_command(
|
|
208
|
+
f"git -C {repository_root} commit -m x",
|
|
209
|
+
working_directory=str(removed_directory),
|
|
210
|
+
)
|
|
211
|
+
assert deny_reason is not None
|
|
212
|
+
assert "email" in deny_reason
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_multiple_dash_c_compose_to_the_named_repo(tmp_path: Path) -> None:
|
|
216
|
+
repository_root = tmp_path / "parent" / "healthy_repo"
|
|
217
|
+
_init_repo_with_staged_pii(repository_root)
|
|
218
|
+
deny_reason = evaluate_bash_command(
|
|
219
|
+
f"git -C {tmp_path / 'parent'} -C healthy_repo commit -m x",
|
|
220
|
+
working_directory="/tmp",
|
|
221
|
+
)
|
|
222
|
+
assert deny_reason is not None
|
|
223
|
+
assert "email" in deny_reason
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_full_dispatch_blocks_pii_commit_from_unrelated_cwd(tmp_path: Path) -> None:
|
|
227
|
+
repository_root = tmp_path / "healthy_repo"
|
|
228
|
+
_init_repo_with_staged_pii(repository_root)
|
|
229
|
+
deny_reason = evaluate(
|
|
230
|
+
{
|
|
231
|
+
"tool_name": "Bash",
|
|
232
|
+
"tool_input": {"command": f"git -C {repository_root} commit -m x"},
|
|
233
|
+
"cwd": "/tmp",
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
assert deny_reason is not None
|
|
237
|
+
assert "email" in deny_reason
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_unresolvable_named_repo_refusal_names_the_path() -> None:
|
|
241
|
+
deny_reason = evaluate_bash_command(
|
|
242
|
+
"git -C /nonexistent/repo/xyz commit -m x",
|
|
243
|
+
working_directory="/tmp",
|
|
244
|
+
)
|
|
245
|
+
assert deny_reason is not None
|
|
246
|
+
assert "repository root" in deny_reason
|
|
247
|
+
assert "/nonexistent/repo/xyz" in deny_reason
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_clean_named_repo_passes(tmp_path: Path) -> None:
|
|
251
|
+
repository_root = tmp_path / "clean_repo"
|
|
252
|
+
repository_root.mkdir()
|
|
253
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
254
|
+
(repository_root / "readme.md").write_text("all clear\n", encoding="utf-8")
|
|
255
|
+
subprocess.run(["git", "add", "readme.md"], cwd=repository_root, check=True)
|
|
256
|
+
deny_reason = evaluate_bash_command(
|
|
257
|
+
f"git -C {repository_root} commit -m x",
|
|
258
|
+
working_directory="/tmp",
|
|
259
|
+
)
|
|
260
|
+
assert deny_reason is None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Behavioral tests for the moved repository-exemption helpers.
|
|
2
|
+
|
|
3
|
+
Covers the origin-URL slug parser and the per-repository exemption decision::
|
|
4
|
+
|
|
5
|
+
https://github.com/Owner/Repo.git -> owner/repo
|
|
6
|
+
https://evil.test/Owner/Repo.git -> None
|
|
7
|
+
ok: origin slug in CLAUDE_PII_EXEMPT_REPOS -> exempt (True)
|
|
8
|
+
flag: origin slug absent from the exempt set -> scanned (False)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from pii_prevention_blocker_parts.repository_exemption import (
|
|
19
|
+
_is_repository_exempt_from_pii_scan,
|
|
20
|
+
_owner_repo_slug_from_origin_url,
|
|
21
|
+
repository_allowlisted_values,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_ALLOWED_VALUE = "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_allowlist_identity(identity_path: Path, slug: str) -> None:
|
|
28
|
+
identity_path.write_text(
|
|
29
|
+
json.dumps({"pii_allowlisted_values": {slug: [_ALLOWED_VALUE]}}),
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_repository_allowlisted_values_returns_the_mapped_values(
|
|
35
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
36
|
+
) -> None:
|
|
37
|
+
identity_path = tmp_path / "local-identity.json"
|
|
38
|
+
_write_allowlist_identity(identity_path, "AllowOwner/allow-repo")
|
|
39
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
40
|
+
repository_root = tmp_path / "repo"
|
|
41
|
+
_init_repo_with_github_origin(repository_root, "AllowOwner/allow-repo")
|
|
42
|
+
assert repository_allowlisted_values(repository_root) == frozenset({_ALLOWED_VALUE})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_repository_allowlisted_values_empty_for_an_unmapped_repo(
|
|
46
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
47
|
+
) -> None:
|
|
48
|
+
identity_path = tmp_path / "local-identity.json"
|
|
49
|
+
_write_allowlist_identity(identity_path, "AllowOwner/allow-repo")
|
|
50
|
+
monkeypatch.setenv("CLAUDE_LOCAL_IDENTITY_PATH", str(identity_path))
|
|
51
|
+
repository_root = tmp_path / "repo"
|
|
52
|
+
_init_repo_with_github_origin(repository_root, "OtherOwner/other-repo")
|
|
53
|
+
assert repository_allowlisted_values(repository_root) == frozenset()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_github_https_origin_yields_lowercased_owner_repo_slug() -> None:
|
|
57
|
+
assert _owner_repo_slug_from_origin_url("https://github.com/Owner/Repo.git") == "owner/repo"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_github_ssh_origin_yields_lowercased_owner_repo_slug() -> None:
|
|
61
|
+
assert _owner_repo_slug_from_origin_url("git@github.com:Owner/Repo.git") == "owner/repo"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_spoofed_host_origin_yields_no_slug() -> None:
|
|
65
|
+
assert _owner_repo_slug_from_origin_url("https://evil.test/Owner/Repo.git") is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _init_repo_with_github_origin(repository_root: Path, origin_slug: str) -> None:
|
|
69
|
+
repository_root.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
71
|
+
origin_url = "https://github.com/" + origin_slug + ".git"
|
|
72
|
+
subprocess.run(
|
|
73
|
+
["git", "remote", "add", "origin", origin_url],
|
|
74
|
+
cwd=repository_root,
|
|
75
|
+
check=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_repository_with_exempt_origin_is_exempt(
|
|
80
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
81
|
+
) -> None:
|
|
82
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
83
|
+
repository_root = tmp_path / "repo"
|
|
84
|
+
_init_repo_with_github_origin(repository_root, "ExemptOwner/exempt-repo")
|
|
85
|
+
assert _is_repository_exempt_from_pii_scan(repository_root) is True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_repository_with_unlisted_origin_is_scanned(
|
|
89
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
90
|
+
) -> None:
|
|
91
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
92
|
+
repository_root = tmp_path / "repo"
|
|
93
|
+
_init_repo_with_github_origin(repository_root, "OtherOwner/other-repo")
|
|
94
|
+
assert _is_repository_exempt_from_pii_scan(repository_root) is False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_repository_without_origin_is_never_exempt(tmp_path: Path) -> None:
|
|
98
|
+
repository_root = tmp_path / "repo"
|
|
99
|
+
repository_root.mkdir()
|
|
100
|
+
subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
|
|
101
|
+
assert _is_repository_exempt_from_pii_scan(repository_root) is False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_owner_repo_slug_strips_a_trailing_slash_origin() -> None:
|
|
105
|
+
assert _owner_repo_slug_from_origin_url("https://github.com/Owner/Repo/") == "owner/repo"
|