claude-dev-env 1.94.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 +41 -33
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
- package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
- 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/CLAUDE.md +10 -1
- package/bin/ever-shipped-skills.mjs +73 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +140 -7
- package/bin/install.prune.test.mjs +457 -0
- package/docs/CODE_RULES.md +1 -1
- 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_enforcer.py +4 -0
- 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 +83 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- 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/plain_language_blocker.py +138 -4
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +97 -601
- 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_code_rules_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_code_rules_shared.py +181 -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_plain_language_blocker_allowlist.py +184 -0
- package/hooks/blocking/test_sensitive_file_protector.py +185 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -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 +4 -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/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +52 -4
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/validators/test_python_style_checks.py +0 -164
- package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
- package/hooks/validators/test_python_style_fixes.py +251 -0
- package/hooks/validators/test_python_style_helpers.py +125 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/anti-corollary-tests.md +69 -0
- package/rules/bdd.md +1 -3
- package/rules/code-reviews.md +1 -1
- package/rules/gh-paginate.md +1 -1
- package/rules/plain-language.md +2 -0
- 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 +6 -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 +202 -18
- 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/README.md +2 -3
- 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/closeout/SKILL.md +153 -0
- package/skills/closeout/reference/handoff-prompt-template.md +72 -0
- package/skills/closeout/reference/issue-body-templates.md +108 -0
- package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
- 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 +32 -22
- package/skills/orchestrator-refresh/SKILL.md +12 -8
- package/skills/pr-converge/CLAUDE.md +1 -1
- 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-fix-protocol/SKILL.md +65 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/skill-builder/references/skill-modularity.md +1 -1
- package/skills/team-advisor/SKILL.md +16 -12
- package/system-prompts/software-engineer.xml +7 -6
- package/hooks/validators/test_verify_paths.py +0 -32
- package/hooks/validators/verify_paths.py +0 -57
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Decision tests for the sensitive-file filename guard.
|
|
2
|
+
|
|
3
|
+
Each case drives the real hook script through its production ``__main__``
|
|
4
|
+
stdin path, feeding it the PreToolUse JSON payload Claude Code sends and
|
|
5
|
+
reading the permission decision back off stdout.
|
|
6
|
+
|
|
7
|
+
The guard denies a filename that names a live secret or a lock file, and
|
|
8
|
+
steps aside for a placeholders-only committed template (``.env.example``
|
|
9
|
+
and its ``.sample`` / ``.template`` spellings). The template exemption is
|
|
10
|
+
narrow: ``.env.local`` is a ``.env.*`` filename that is not a template, and
|
|
11
|
+
it stays denied.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
24
|
+
HOOK_SCRIPT_PATH = BLOCKING_DIRECTORY / "sensitive_file_protector.py"
|
|
25
|
+
|
|
26
|
+
WRITE_TOOL_NAME = "Write"
|
|
27
|
+
EDIT_TOOL_NAME = "Edit"
|
|
28
|
+
READ_TOOL_NAME = "Read"
|
|
29
|
+
|
|
30
|
+
DENY_DECISION = "deny"
|
|
31
|
+
|
|
32
|
+
PLACEHOLDER_TEMPLATE_BODY = "API_TOKEN=your-token-here\nDATABASE_HOST=localhost\n"
|
|
33
|
+
ORDINARY_SOURCE_BODY = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
34
|
+
|
|
35
|
+
ALL_TEMPLATE_FILENAMES = (
|
|
36
|
+
".env.example",
|
|
37
|
+
".env.sample",
|
|
38
|
+
".env.template",
|
|
39
|
+
".ENV.Example",
|
|
40
|
+
"settings.json.template",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
ALL_DENIED_FILENAMES = (
|
|
44
|
+
".env",
|
|
45
|
+
".env.local",
|
|
46
|
+
".env.production",
|
|
47
|
+
"credentials.json",
|
|
48
|
+
"secrets.json",
|
|
49
|
+
"id_rsa",
|
|
50
|
+
"id_ed25519",
|
|
51
|
+
"server.pem",
|
|
52
|
+
"signing.key",
|
|
53
|
+
"bundle.p12",
|
|
54
|
+
"bundle.pfx",
|
|
55
|
+
"package-lock.json",
|
|
56
|
+
"yarn.lock",
|
|
57
|
+
"Pipfile.lock",
|
|
58
|
+
"poetry.lock",
|
|
59
|
+
"pnpm-lock.yaml",
|
|
60
|
+
"composer.lock",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
ALL_UPPERCASE_DENIED_FILENAMES = (".ENV", "ID_RSA", "CREDENTIALS.JSON", "SERVER.PEM")
|
|
64
|
+
|
|
65
|
+
ALL_ORDINARY_FILENAMES = ("main.py", "README.md", "settings.json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run_hook(tool_name: str, file_path: Path, content: str) -> subprocess.CompletedProcess:
|
|
69
|
+
payload = json.dumps(
|
|
70
|
+
{
|
|
71
|
+
"tool_name": tool_name,
|
|
72
|
+
"tool_input": {"file_path": str(file_path), "content": content},
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
return _run_hook_with_stdin(payload)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run_hook_with_stdin(stdin_text: str) -> subprocess.CompletedProcess:
|
|
79
|
+
return subprocess.run(
|
|
80
|
+
[sys.executable, str(HOOK_SCRIPT_PATH)],
|
|
81
|
+
input=stdin_text,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _permission_decision(completed: subprocess.CompletedProcess) -> str | None:
|
|
89
|
+
if not completed.stdout.strip():
|
|
90
|
+
return None
|
|
91
|
+
parsed_decision = json.loads(completed.stdout)
|
|
92
|
+
return parsed_decision["hookSpecificOutput"]["permissionDecision"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _deny_reason(completed: subprocess.CompletedProcess) -> str:
|
|
96
|
+
parsed_decision = json.loads(completed.stdout)
|
|
97
|
+
return parsed_decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize("template_filename", ALL_TEMPLATE_FILENAMES)
|
|
101
|
+
def test_template_filename_is_allowed(tmp_path: Path, template_filename: str) -> None:
|
|
102
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / template_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
103
|
+
assert completed.returncode == 0, completed.stderr
|
|
104
|
+
assert _permission_decision(completed) is None, (
|
|
105
|
+
f"{template_filename} is a committed template and must not be denied by filename; "
|
|
106
|
+
f"got stdout {completed.stdout!r}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("sensitive_filename", ALL_DENIED_FILENAMES)
|
|
111
|
+
def test_sensitive_filename_is_denied(tmp_path: Path, sensitive_filename: str) -> None:
|
|
112
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / sensitive_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
113
|
+
assert completed.returncode == 0, completed.stderr
|
|
114
|
+
assert _permission_decision(completed) == DENY_DECISION, (
|
|
115
|
+
f"{sensitive_filename} must stay denied; got stdout {completed.stdout!r}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("uppercase_filename", ALL_UPPERCASE_DENIED_FILENAMES)
|
|
120
|
+
def test_uppercase_sensitive_filename_is_denied(tmp_path: Path, uppercase_filename: str) -> None:
|
|
121
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / uppercase_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
122
|
+
assert completed.returncode == 0, completed.stderr
|
|
123
|
+
assert _permission_decision(completed) == DENY_DECISION, (
|
|
124
|
+
f"{uppercase_filename} names a secret in an uppercase spelling and must stay denied like "
|
|
125
|
+
f"its lowercase form; got stdout {completed.stdout!r}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.parametrize("ordinary_filename", ALL_ORDINARY_FILENAMES)
|
|
130
|
+
def test_ordinary_source_file_is_allowed(tmp_path: Path, ordinary_filename: str) -> None:
|
|
131
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ordinary_filename, ORDINARY_SOURCE_BODY)
|
|
132
|
+
assert completed.returncode == 0, completed.stderr
|
|
133
|
+
assert _permission_decision(completed) is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_dot_env_local_deny_reason_names_the_file_and_the_pattern(tmp_path: Path) -> None:
|
|
137
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ".env.local", PLACEHOLDER_TEMPLATE_BODY)
|
|
138
|
+
assert _permission_decision(completed) == DENY_DECISION
|
|
139
|
+
deny_reason = _deny_reason(completed)
|
|
140
|
+
assert ".env.local" in deny_reason
|
|
141
|
+
assert ".env.*" in deny_reason
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_edit_to_a_template_is_allowed(tmp_path: Path) -> None:
|
|
145
|
+
completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env.example", PLACEHOLDER_TEMPLATE_BODY)
|
|
146
|
+
assert completed.returncode == 0, completed.stderr
|
|
147
|
+
assert _permission_decision(completed) is None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
151
|
+
completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
|
|
152
|
+
assert completed.returncode == 0, completed.stderr
|
|
153
|
+
assert _permission_decision(completed) == DENY_DECISION
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_read_tool_is_not_evaluated(tmp_path: Path) -> None:
|
|
157
|
+
completed = _run_hook(READ_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
|
|
158
|
+
assert completed.returncode == 0, completed.stderr
|
|
159
|
+
assert _permission_decision(completed) is None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_payload_without_a_file_path_is_allowed() -> None:
|
|
163
|
+
completed = _run_hook_with_stdin(json.dumps({"tool_name": WRITE_TOOL_NAME, "tool_input": {}}))
|
|
164
|
+
assert completed.returncode == 0, completed.stderr
|
|
165
|
+
assert _permission_decision(completed) is None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_malformed_payload_fails_open() -> None:
|
|
169
|
+
completed = _run_hook_with_stdin("{not valid json")
|
|
170
|
+
assert completed.returncode == 0, completed.stderr
|
|
171
|
+
assert completed.stdout.strip() == ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.parametrize("trailing_suffix_filename", (".env.example.bak", ".env.template.old", ".env.sample.orig"))
|
|
175
|
+
def test_template_suffix_must_be_last_to_earn_the_exemption(
|
|
176
|
+
tmp_path: Path, trailing_suffix_filename: str
|
|
177
|
+
) -> None:
|
|
178
|
+
completed = _run_hook(
|
|
179
|
+
WRITE_TOOL_NAME, tmp_path / trailing_suffix_filename, PLACEHOLDER_TEMPLATE_BODY
|
|
180
|
+
)
|
|
181
|
+
assert completed.returncode == 0, completed.stderr
|
|
182
|
+
assert _permission_decision(completed) == DENY_DECISION, (
|
|
183
|
+
f"{trailing_suffix_filename} is a copy of a live secrets file, not a template; "
|
|
184
|
+
f"only a trailing template suffix earns the exemption, got {completed.stdout!r}"
|
|
185
|
+
)
|
|
@@ -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,105 @@
|
|
|
1
|
+
"""Tests for the session-scratchpad exemption wired into tdd_enforcer.main."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
TDD_ENFORCER_PATH = Path(__file__).parent / "tdd_enforcer.py"
|
|
15
|
+
FIXED_USER_ID = 5150
|
|
16
|
+
WORKING_DIRECTORY = "/home/user/project"
|
|
17
|
+
SESSION_ID = "tdd-session-987"
|
|
18
|
+
PRODUCTION_CONTENT = "def fulfill_order():\n return True\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_tdd_enforcer() -> ModuleType:
|
|
22
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
23
|
+
"tdd_enforcer_scratchpad_under_test", TDD_ENFORCER_PATH
|
|
24
|
+
)
|
|
25
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
26
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
27
|
+
module_spec.loader.exec_module(loaded_module)
|
|
28
|
+
return loaded_module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_TDD_ENFORCER = _load_tdd_enforcer()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _install_scratchpad_signals(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> Path:
|
|
35
|
+
monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
|
|
36
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
37
|
+
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
38
|
+
scratchpad_directory = (
|
|
39
|
+
temporary_root
|
|
40
|
+
/ f"claude-{FIXED_USER_ID}"
|
|
41
|
+
/ mangled_working_directory
|
|
42
|
+
/ SESSION_ID
|
|
43
|
+
/ "scratchpad"
|
|
44
|
+
)
|
|
45
|
+
scratchpad_directory.mkdir(parents=True)
|
|
46
|
+
return scratchpad_directory
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write_payload(target: Path) -> dict[str, object]:
|
|
50
|
+
return {
|
|
51
|
+
"tool_name": "Write",
|
|
52
|
+
"cwd": WORKING_DIRECTORY,
|
|
53
|
+
"session_id": SESSION_ID,
|
|
54
|
+
"tool_input": {"file_path": str(target), "content": PRODUCTION_CONTENT},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run_main(
|
|
59
|
+
monkeypatch: pytest.MonkeyPatch, payload: dict[str, object]
|
|
60
|
+
) -> tuple[int | None, str]:
|
|
61
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
62
|
+
captured_stdout = io.StringIO()
|
|
63
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
64
|
+
exit_code: int | None = None
|
|
65
|
+
try:
|
|
66
|
+
_TDD_ENFORCER.main()
|
|
67
|
+
except SystemExit as raised_exit:
|
|
68
|
+
raw_code = raised_exit.code
|
|
69
|
+
exit_code = raw_code if isinstance(raw_code, int) else None
|
|
70
|
+
return exit_code, captured_stdout.getvalue()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decision_from(stdout_text: str) -> str | None:
|
|
74
|
+
if not stdout_text.strip():
|
|
75
|
+
return None
|
|
76
|
+
parsed = json.loads(stdout_text)
|
|
77
|
+
return parsed.get("hookSpecificOutput", {}).get("permissionDecision")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_scratchpad_production_write_is_exempt_from_tdd_gate(
|
|
81
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
82
|
+
) -> None:
|
|
83
|
+
scratchpad_directory = _install_scratchpad_signals(monkeypatch, tmp_path)
|
|
84
|
+
monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
|
|
85
|
+
throwaway_script = scratchpad_directory / "one_off_probe.py"
|
|
86
|
+
|
|
87
|
+
exit_code, stdout_text = _run_main(monkeypatch, _write_payload(throwaway_script))
|
|
88
|
+
|
|
89
|
+
assert exit_code == 0
|
|
90
|
+
assert _decision_from(stdout_text) != "deny"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_identical_non_scratchpad_write_is_still_blocked(
|
|
94
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
95
|
+
) -> None:
|
|
96
|
+
_install_scratchpad_signals(monkeypatch, tmp_path)
|
|
97
|
+
monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
|
|
98
|
+
outside_directory = tmp_path / "project" / "orders"
|
|
99
|
+
outside_directory.mkdir(parents=True)
|
|
100
|
+
production_module = outside_directory / "one_off_probe.py"
|
|
101
|
+
|
|
102
|
+
exit_code, stdout_text = _run_main(monkeypatch, _write_payload(production_module))
|
|
103
|
+
|
|
104
|
+
assert exit_code == 0
|
|
105
|
+
assert _decision_from(stdout_text) == "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
|