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,181 @@
|
|
|
1
|
+
"""Tests for the harness session scratchpad exemption in code_rules_shared."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
SHARED_MODULE_PATH = Path(__file__).parent / "code_rules_shared.py"
|
|
12
|
+
FIXED_USER_ID = 4242
|
|
13
|
+
WORKING_DIRECTORY = "/home/user/project"
|
|
14
|
+
SESSION_ID = "session-abc-123"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_shared_module() -> ModuleType:
|
|
18
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
19
|
+
"code_rules_shared_under_test", SHARED_MODULE_PATH
|
|
20
|
+
)
|
|
21
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
22
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
23
|
+
module_spec.loader.exec_module(loaded_module)
|
|
24
|
+
return loaded_module
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_SHARED_MODULE = _load_shared_module()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _install_fixed_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
31
|
+
monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _point_temporary_directory_at(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
|
|
35
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_scratchpad_directory(temporary_root: Path) -> Path:
|
|
39
|
+
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
40
|
+
scratchpad_directory = (
|
|
41
|
+
temporary_root
|
|
42
|
+
/ f"claude-{FIXED_USER_ID}"
|
|
43
|
+
/ mangled_working_directory
|
|
44
|
+
/ SESSION_ID
|
|
45
|
+
/ "scratchpad"
|
|
46
|
+
)
|
|
47
|
+
scratchpad_directory.mkdir(parents=True)
|
|
48
|
+
return scratchpad_directory
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _session_payload() -> dict[str, str]:
|
|
52
|
+
return {"cwd": WORKING_DIRECTORY, "session_id": SESSION_ID}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_returns_true_for_file_inside_reconstructed_scratchpad(
|
|
56
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
57
|
+
) -> None:
|
|
58
|
+
_install_fixed_user_id(monkeypatch)
|
|
59
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
60
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
61
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
62
|
+
|
|
63
|
+
assert (
|
|
64
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
|
|
65
|
+
is True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_returns_false_for_file_outside_scratchpad(
|
|
70
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
71
|
+
) -> None:
|
|
72
|
+
_install_fixed_user_id(monkeypatch)
|
|
73
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
74
|
+
_build_scratchpad_directory(tmp_path)
|
|
75
|
+
project_module = tmp_path / "elsewhere" / "orders.py"
|
|
76
|
+
|
|
77
|
+
assert (
|
|
78
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(project_module), _session_payload()) is False
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_resolves_symlink_into_scratchpad_to_true(
|
|
83
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
84
|
+
) -> None:
|
|
85
|
+
_install_fixed_user_id(monkeypatch)
|
|
86
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
87
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
88
|
+
real_script = scratchpad_directory / "real_tool.py"
|
|
89
|
+
real_script.write_text("value = 1\n")
|
|
90
|
+
link_outside = tmp_path / "link_to_scratchpad.py"
|
|
91
|
+
link_outside.symlink_to(real_script)
|
|
92
|
+
|
|
93
|
+
assert _SHARED_MODULE.is_under_session_scratchpad(str(link_outside), _session_payload()) is True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_symlink_resolving_outside_scratchpad_is_false(
|
|
97
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
98
|
+
) -> None:
|
|
99
|
+
_install_fixed_user_id(monkeypatch)
|
|
100
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
101
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
102
|
+
real_target_outside = tmp_path / "outside_target.py"
|
|
103
|
+
real_target_outside.write_text("value = 1\n")
|
|
104
|
+
link_inside_scratchpad = scratchpad_directory / "sneaky_link.py"
|
|
105
|
+
link_inside_scratchpad.symlink_to(real_target_outside)
|
|
106
|
+
|
|
107
|
+
assert (
|
|
108
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(link_inside_scratchpad), _session_payload())
|
|
109
|
+
is False
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_missing_session_id_signal_returns_false(
|
|
114
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
115
|
+
) -> None:
|
|
116
|
+
_install_fixed_user_id(monkeypatch)
|
|
117
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
118
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
119
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
120
|
+
payload_without_session = {"cwd": WORKING_DIRECTORY}
|
|
121
|
+
|
|
122
|
+
assert (
|
|
123
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_session)
|
|
124
|
+
is False
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_missing_working_directory_signal_returns_false(
|
|
129
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
130
|
+
) -> None:
|
|
131
|
+
_install_fixed_user_id(monkeypatch)
|
|
132
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
133
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
134
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
135
|
+
payload_without_cwd = {"session_id": SESSION_ID}
|
|
136
|
+
|
|
137
|
+
assert (
|
|
138
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_cwd)
|
|
139
|
+
is False
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_absent_getuid_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
144
|
+
_install_fixed_user_id(monkeypatch)
|
|
145
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
146
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
147
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
148
|
+
monkeypatch.delattr(os, "getuid", raising=False)
|
|
149
|
+
|
|
150
|
+
assert (
|
|
151
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
|
|
152
|
+
is False
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_nonexistent_scratchpad_directory_returns_false(
|
|
157
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
158
|
+
) -> None:
|
|
159
|
+
_install_fixed_user_id(monkeypatch)
|
|
160
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
161
|
+
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
162
|
+
unbuilt_target = (
|
|
163
|
+
tmp_path
|
|
164
|
+
/ f"claude-{FIXED_USER_ID}"
|
|
165
|
+
/ mangled_working_directory
|
|
166
|
+
/ SESSION_ID
|
|
167
|
+
/ "scratchpad"
|
|
168
|
+
/ "one_off_tool.py"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
assert (
|
|
172
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(unbuilt_target), _session_payload()) is False
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_empty_file_path_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
177
|
+
_install_fixed_user_id(monkeypatch)
|
|
178
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
179
|
+
_build_scratchpad_directory(tmp_path)
|
|
180
|
+
|
|
181
|
+
assert _SHARED_MODULE.is_under_session_scratchpad("", _session_payload()) is False
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""End-to-end tests for the inventory/orphan pending-intent deadlock resolution.
|
|
2
|
+
|
|
3
|
+
Each test drives the two real hook binaries through their production ``main()``
|
|
4
|
+
stdin path over a temp directory tree whose ``CLAUDE.md`` inventory names two
|
|
5
|
+
sibling files. A shared ``session_id`` and an isolated ``TMPDIR`` make the two
|
|
6
|
+
hooks read one shared intent-records file, so a deny by one hook opens the gate
|
|
7
|
+
for the sibling's second write — in either add order.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
|
|
18
|
+
INVENTORY_HOOK = "package_inventory_stale_blocker.py"
|
|
19
|
+
ORPHAN_HOOK = "claude_md_orphan_file_blocker.py"
|
|
20
|
+
|
|
21
|
+
SESSION_ID = "deadlock-session"
|
|
22
|
+
|
|
23
|
+
INVENTORY_TABLE_HEADER = "# pkg\n\n| File | Purpose |\n|---|---|\n"
|
|
24
|
+
ALPHA_ROW = "| `alpha.py` | Does A |\n"
|
|
25
|
+
BETA_ROW = "| `beta.py` | Does B |\n"
|
|
26
|
+
GAMMA_ROW = "| `gamma.py` | Does G |\n"
|
|
27
|
+
EPSILON_ROW = "| `epsilon.py` | Does E |\n"
|
|
28
|
+
|
|
29
|
+
BASE_INVENTORY = INVENTORY_TABLE_HEADER + ALPHA_ROW + BETA_ROW
|
|
30
|
+
INVENTORY_WITH_GAMMA = BASE_INVENTORY + GAMMA_ROW
|
|
31
|
+
INVENTORY_WITH_EPSILON = BASE_INVENTORY + EPSILON_ROW
|
|
32
|
+
|
|
33
|
+
MODULE_BODY = "x = 1\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _package_directory(tmp_path: Path) -> Path:
|
|
37
|
+
"""Return a package directory holding a CLAUDE.md table naming two siblings."""
|
|
38
|
+
package_directory = tmp_path / "pkg"
|
|
39
|
+
package_directory.mkdir()
|
|
40
|
+
(package_directory / "CLAUDE.md").write_text(BASE_INVENTORY, encoding="utf-8")
|
|
41
|
+
(package_directory / "alpha.py").write_text(MODULE_BODY, encoding="utf-8")
|
|
42
|
+
(package_directory / "beta.py").write_text(MODULE_BODY, encoding="utf-8")
|
|
43
|
+
return package_directory
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_hook(
|
|
47
|
+
hook_filename: str, tool_name: str, tool_input: dict, temp_directory: Path
|
|
48
|
+
) -> subprocess.CompletedProcess:
|
|
49
|
+
"""Run one hook binary over a payload, sharing an isolated intent-records store."""
|
|
50
|
+
payload = json.dumps(
|
|
51
|
+
{"tool_name": tool_name, "tool_input": tool_input, "session_id": SESSION_ID}
|
|
52
|
+
)
|
|
53
|
+
hook_environment = dict(os.environ)
|
|
54
|
+
hook_environment["TMPDIR"] = str(temp_directory)
|
|
55
|
+
return subprocess.run(
|
|
56
|
+
[sys.executable, str(BLOCKING_DIRECTORY / hook_filename)],
|
|
57
|
+
input=payload,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
check=False,
|
|
61
|
+
env=hook_environment,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decision(completed: subprocess.CompletedProcess) -> str | None:
|
|
66
|
+
"""Return the permission decision a hook emitted, or None when it allowed."""
|
|
67
|
+
if not completed.stdout.strip():
|
|
68
|
+
return None
|
|
69
|
+
return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecision"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _reason(completed: subprocess.CompletedProcess) -> str:
|
|
73
|
+
"""Return the deny reason a hook emitted."""
|
|
74
|
+
return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecisionReason"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_file_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
|
|
78
|
+
package_directory = _package_directory(tmp_path)
|
|
79
|
+
new_file = package_directory / "gamma.py"
|
|
80
|
+
claude_md = package_directory / "CLAUDE.md"
|
|
81
|
+
|
|
82
|
+
first_write = _run_hook(
|
|
83
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
84
|
+
)
|
|
85
|
+
assert _decision(first_write) == "deny"
|
|
86
|
+
assert "gamma.py" in _reason(first_write)
|
|
87
|
+
assert "recorded" in _reason(first_write)
|
|
88
|
+
|
|
89
|
+
row_write = _run_hook(
|
|
90
|
+
ORPHAN_HOOK,
|
|
91
|
+
"Write",
|
|
92
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
|
|
93
|
+
tmp_path,
|
|
94
|
+
)
|
|
95
|
+
assert _decision(row_write) is None
|
|
96
|
+
|
|
97
|
+
claude_md.write_text(INVENTORY_WITH_GAMMA, encoding="utf-8")
|
|
98
|
+
retry_write = _run_hook(
|
|
99
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
100
|
+
)
|
|
101
|
+
assert _decision(retry_write) is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_row_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
|
|
105
|
+
package_directory = _package_directory(tmp_path)
|
|
106
|
+
new_file = package_directory / "gamma.py"
|
|
107
|
+
claude_md = package_directory / "CLAUDE.md"
|
|
108
|
+
|
|
109
|
+
first_row = _run_hook(
|
|
110
|
+
ORPHAN_HOOK,
|
|
111
|
+
"Write",
|
|
112
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
|
|
113
|
+
tmp_path,
|
|
114
|
+
)
|
|
115
|
+
assert _decision(first_row) == "deny"
|
|
116
|
+
assert "gamma.py" in _reason(first_row)
|
|
117
|
+
assert "recorded" in _reason(first_row)
|
|
118
|
+
|
|
119
|
+
file_write = _run_hook(
|
|
120
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
121
|
+
)
|
|
122
|
+
assert _decision(file_write) is None
|
|
123
|
+
|
|
124
|
+
new_file.write_text(MODULE_BODY, encoding="utf-8")
|
|
125
|
+
retry_row = _run_hook(
|
|
126
|
+
ORPHAN_HOOK,
|
|
127
|
+
"Write",
|
|
128
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
|
|
129
|
+
tmp_path,
|
|
130
|
+
)
|
|
131
|
+
assert _decision(retry_row) is None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_new_file_with_no_row_attempt_stays_blocked(tmp_path: Path) -> None:
|
|
135
|
+
package_directory = _package_directory(tmp_path)
|
|
136
|
+
new_file = package_directory / "delta.py"
|
|
137
|
+
file_write = _run_hook(
|
|
138
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
139
|
+
)
|
|
140
|
+
assert _decision(file_write) == "deny"
|
|
141
|
+
assert "delta.py" in _reason(file_write)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_row_naming_uncreated_file_stays_blocked(tmp_path: Path) -> None:
|
|
145
|
+
package_directory = _package_directory(tmp_path)
|
|
146
|
+
claude_md = package_directory / "CLAUDE.md"
|
|
147
|
+
row_write = _run_hook(
|
|
148
|
+
ORPHAN_HOOK,
|
|
149
|
+
"Write",
|
|
150
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_EPSILON},
|
|
151
|
+
tmp_path,
|
|
152
|
+
)
|
|
153
|
+
assert _decision(row_write) == "deny"
|
|
154
|
+
assert "epsilon.py" in _reason(row_write)
|
|
@@ -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,184 @@
|
|
|
1
|
+
"""Tests for the per-project domain-vocabulary allowlist in plain_language_blocker."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
|
|
8
|
+
BLOCKER_PATH = Path(__file__).parent / "plain_language_blocker.py"
|
|
9
|
+
ALLOWLIST_RELATIVE_PATH = Path(".claude") / "plain-language-allow.json"
|
|
10
|
+
ALWAYS_HEAVY_WORD = "utilize"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_blocker() -> ModuleType:
|
|
14
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"plain_language_blocker_allowlist_under_test", BLOCKER_PATH
|
|
16
|
+
)
|
|
17
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
18
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
19
|
+
module_spec.loader.exec_module(loaded_module)
|
|
20
|
+
return loaded_module
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_BLOCKER = _load_blocker()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _init_repo(root: Path) -> Path:
|
|
27
|
+
(root / ".git").mkdir(parents=True, exist_ok=True)
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_allowlist(project_root: Path, all_words: list[str]) -> None:
|
|
32
|
+
allowlist_path = project_root / ALLOWLIST_RELATIVE_PATH
|
|
33
|
+
allowlist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
allowlist_path.write_text(json.dumps(all_words), encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_raw_allowlist(project_root: Path, raw_text: str) -> None:
|
|
38
|
+
allowlist_path = project_root / ALLOWLIST_RELATIVE_PATH
|
|
39
|
+
allowlist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
allowlist_path.write_text(raw_text, encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _markdown_write_payload(project_root: Path, prose: str) -> dict[str, object]:
|
|
44
|
+
document_path = project_root / "docs" / "notes.md"
|
|
45
|
+
return {
|
|
46
|
+
"tool_name": "Write",
|
|
47
|
+
"cwd": str(project_root),
|
|
48
|
+
"tool_input": {"file_path": str(document_path), "content": prose},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ask_user_question_payload(project_root: Path, prose: str) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"tool_name": "AskUserQuestion",
|
|
55
|
+
"cwd": str(project_root),
|
|
56
|
+
"tool_input": {"questions": [{"question": prose, "options": []}]},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_allowlisted_word_passes_in_markdown_write(tmp_path: Path) -> None:
|
|
61
|
+
prose = "Please submit the release notes."
|
|
62
|
+
project_without_allowlist = _init_repo(tmp_path / "control")
|
|
63
|
+
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
64
|
+
_write_allowlist(project_with_allowlist, ["submit"])
|
|
65
|
+
|
|
66
|
+
control_deny_reason = _BLOCKER.evaluate(
|
|
67
|
+
_markdown_write_payload(project_without_allowlist, prose)
|
|
68
|
+
)
|
|
69
|
+
allowlisted_deny_reason = _BLOCKER.evaluate(
|
|
70
|
+
_markdown_write_payload(project_with_allowlist, prose)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert control_deny_reason is not None and "submit" in control_deny_reason
|
|
74
|
+
assert allowlisted_deny_reason is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_allowlisted_word_passes_in_ask_user_question(tmp_path: Path) -> None:
|
|
78
|
+
prose = "Which theme should we identify first?"
|
|
79
|
+
project_without_allowlist = _init_repo(tmp_path / "control")
|
|
80
|
+
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
81
|
+
_write_allowlist(project_with_allowlist, ["identify"])
|
|
82
|
+
|
|
83
|
+
control_deny_reason = _BLOCKER.evaluate(
|
|
84
|
+
_ask_user_question_payload(project_without_allowlist, prose)
|
|
85
|
+
)
|
|
86
|
+
allowlisted_deny_reason = _BLOCKER.evaluate(
|
|
87
|
+
_ask_user_question_payload(project_with_allowlist, prose)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert control_deny_reason is not None and "identify" in control_deny_reason
|
|
91
|
+
assert allowlisted_deny_reason is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_non_allowlisted_heavy_word_still_blocked(tmp_path: Path) -> None:
|
|
95
|
+
_init_repo(tmp_path)
|
|
96
|
+
_write_allowlist(tmp_path, ["submit"])
|
|
97
|
+
|
|
98
|
+
deny_reason = _BLOCKER.evaluate(
|
|
99
|
+
_markdown_write_payload(tmp_path, f"Please {ALWAYS_HEAVY_WORD} the cache now.")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert deny_reason is not None and ALWAYS_HEAVY_WORD in deny_reason
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_allowlist_match_is_case_insensitive(tmp_path: Path) -> None:
|
|
106
|
+
prose = "Please SUBMIT the release notes."
|
|
107
|
+
project_without_allowlist = _init_repo(tmp_path / "control")
|
|
108
|
+
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
109
|
+
_write_allowlist(project_with_allowlist, ["Submit"])
|
|
110
|
+
|
|
111
|
+
control_deny_reason = _BLOCKER.evaluate(
|
|
112
|
+
_markdown_write_payload(project_without_allowlist, prose)
|
|
113
|
+
)
|
|
114
|
+
allowlisted_deny_reason = _BLOCKER.evaluate(
|
|
115
|
+
_markdown_write_payload(project_with_allowlist, prose)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert control_deny_reason is not None and "submit" in control_deny_reason
|
|
119
|
+
assert allowlisted_deny_reason is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_malformed_allowlist_json_is_ignored(tmp_path: Path) -> None:
|
|
123
|
+
_init_repo(tmp_path)
|
|
124
|
+
_write_raw_allowlist(tmp_path, "{ this is not valid json ")
|
|
125
|
+
|
|
126
|
+
deny_reason = _BLOCKER.evaluate(
|
|
127
|
+
_markdown_write_payload(tmp_path, "Please submit the release notes.")
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_allowlist_in_a_different_project_root_is_not_applied(tmp_path: Path) -> None:
|
|
134
|
+
project_with_allowlist = _init_repo(tmp_path / "project_a")
|
|
135
|
+
project_without_allowlist = _init_repo(tmp_path / "project_b")
|
|
136
|
+
_write_allowlist(project_with_allowlist, ["submit"])
|
|
137
|
+
|
|
138
|
+
deny_reason = _BLOCKER.evaluate(
|
|
139
|
+
_markdown_write_payload(project_without_allowlist, "Please submit the notes.")
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_allowlist_above_the_repo_root_is_not_applied(tmp_path: Path) -> None:
|
|
146
|
+
_write_allowlist(tmp_path, ["submit"])
|
|
147
|
+
repository_root = _init_repo(tmp_path / "repo")
|
|
148
|
+
|
|
149
|
+
deny_reason = _BLOCKER.evaluate(
|
|
150
|
+
_markdown_write_payload(repository_root, "Please submit the notes.")
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_allowlist_at_the_repo_root_is_applied(tmp_path: Path) -> None:
|
|
157
|
+
repository_root = _init_repo(tmp_path / "repo")
|
|
158
|
+
_write_allowlist(repository_root, ["submit"])
|
|
159
|
+
|
|
160
|
+
deny_reason = _BLOCKER.evaluate(
|
|
161
|
+
_markdown_write_payload(repository_root, "Please submit the notes.")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert deny_reason is None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_allowlist_without_a_repo_root_is_not_applied(tmp_path: Path) -> None:
|
|
168
|
+
_write_allowlist(tmp_path, ["submit"])
|
|
169
|
+
|
|
170
|
+
deny_reason = _BLOCKER.evaluate(
|
|
171
|
+
_markdown_write_payload(tmp_path, "Please submit the notes.")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_find_banned_terms_skips_allowlisted_terms() -> None:
|
|
178
|
+
matched_without_allowlist = _BLOCKER.find_banned_terms("Please submit the notes.")
|
|
179
|
+
matched_with_allowlist = _BLOCKER.find_banned_terms(
|
|
180
|
+
"Please submit the notes.", frozenset({"submit"})
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert any(each_term == "submit" for each_term, _ in matched_without_allowlist)
|
|
184
|
+
assert all(each_term != "submit" for each_term, _ in matched_with_allowlist)
|