claude-dev-env 1.93.1 → 1.95.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 +35 -27
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
- package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/audit-contract.md +1 -1
- package/_shared/pr-loop/gh-payloads.md +3 -3
- package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
- package/_shared/pr-loop/scripts/README.md +2 -2
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
- package/_shared/pr-loop/state-schema.md +3 -22
- package/bin/CLAUDE.md +11 -2
- package/bin/ever-shipped-skills.mjs +70 -0
- package/bin/install.mjs +138 -13
- package/bin/install.prune.test.mjs +457 -0
- package/bin/install.test.mjs +10 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_shared.py +82 -0
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- package/hooks/blocking/pii_commit_command.py +398 -0
- package/hooks/blocking/pii_payload_scan.py +133 -0
- package/hooks/blocking/pii_prevention_blocker.py +151 -449
- package/hooks/blocking/plain_language_blocker.py +138 -4
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +9 -2
- 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_pii_prevention_blocker.py +299 -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_scratchpad.py +105 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +4 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/hooks/pyproject.toml +75 -4
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -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/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 +10 -1
- package/scripts/Get-SessionAccount.ps1 +210 -0
- package/scripts/tests/CLAUDE.md +15 -1
- package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
- package/skills/CLAUDE.md +6 -27
- package/skills/anthropic-plan/SKILL.md +1 -13
- package/skills/autoconverge/CLAUDE.md +5 -2
- package/skills/autoconverge/SKILL.md +112 -387
- package/skills/autoconverge/reference/CLAUDE.md +4 -1
- package/skills/autoconverge/reference/closing-report.md +9 -2
- package/skills/autoconverge/reference/convergence.md +26 -22
- package/skills/autoconverge/reference/headless-safety.md +44 -0
- package/skills/autoconverge/reference/multi-pr.md +88 -0
- package/skills/autoconverge/reference/self-closing-loop.md +84 -0
- package/skills/autoconverge/reference/stop-conditions.md +21 -13
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
- package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +286 -96
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/CONSTRAINTS.md +3 -3
- package/skills/bugteam/PROMPTS.md +8 -19
- package/skills/bugteam/SKILL.md +13 -14
- package/skills/bugteam/reference/CLAUDE.md +1 -1
- package/skills/bugteam/reference/README.md +2 -3
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
- package/skills/bugteam/reference/github-pr-reviews.md +56 -38
- package/skills/bugteam/reference/team-setup.md +11 -11
- package/skills/bugteam/scripts/CLAUDE.md +6 -6
- package/skills/bugteam/scripts/README.md +20 -8
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
- package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
- package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
- 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/copilot-finding-triage/SKILL.md +80 -28
- package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
- package/skills/copilot-finding-triage/templates/notification.md +8 -3
- package/skills/everything-search/SKILL.md +1 -2
- package/skills/grokify/SKILL.md +58 -0
- package/skills/grokify/templates/handoff-template.md +92 -0
- package/skills/orchestrator/SKILL.md +35 -25
- package/skills/orchestrator-refresh/SKILL.md +15 -11
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +132 -233
- package/skills/pr-converge/reference/convergence-gates.md +102 -66
- package/skills/pr-converge/reference/examples.md +37 -25
- package/skills/pr-converge/reference/fix-protocol.md +7 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
- package/skills/pr-converge/reference/per-tick.md +64 -41
- package/skills/pr-converge/reference/state-schema.md +27 -2
- package/skills/pr-converge/scripts/CLAUDE.md +8 -0
- package/skills/pr-converge/scripts/README.md +4 -3
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
- package/skills/pr-converge/scripts/check_convergence.py +189 -645
- package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
- package/skills/pr-converge/scripts/conftest.py +8 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
- package/skills/pr-fix-protocol/SKILL.md +20 -20
- package/skills/privacy-hygiene/SKILL.md +4 -3
- package/skills/reviewer-gates/SKILL.md +26 -8
- package/skills/session-log/SKILL.md +11 -12
- package/skills/session-tidy/SKILL.md +40 -30
- package/skills/skill-builder/CLAUDE.md +13 -10
- package/skills/skill-builder/SKILL.md +31 -31
- package/skills/skill-builder/references/CLAUDE.md +10 -6
- package/skills/skill-builder/references/delegation-map.md +13 -4
- package/skills/skill-builder/references/description-field.md +113 -0
- package/skills/skill-builder/references/self-audit-checklist.md +21 -4
- package/skills/skill-builder/references/skill-modularity.md +113 -0
- package/skills/skill-builder/references/skill-types.md +4 -0
- package/skills/skill-builder/templates/CLAUDE.md +4 -4
- package/skills/skill-builder/templates/gap-analysis.md +25 -1
- package/skills/skill-builder/workflows/CLAUDE.md +6 -4
- package/skills/skill-builder/workflows/improve-skill.md +17 -16
- package/skills/skill-builder/workflows/new-skill.md +34 -10
- package/skills/skill-builder/workflows/polish-skill.md +59 -43
- package/skills/team-advisor/SKILL.md +15 -11
- package/skills/usage-pause/SKILL.md +10 -14
- 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
- package/skills/bdd-protocol/CLAUDE.md +0 -26
- package/skills/bdd-protocol/SKILL.md +0 -30
- package/skills/bdd-protocol/references/CLAUDE.md +0 -10
- package/skills/bdd-protocol/references/anti-patterns.md +0 -26
- package/skills/bdd-protocol/references/example-mapping.md +0 -23
- package/skills/bg-agent/CLAUDE.md +0 -17
- package/skills/bg-agent/SKILL.md +0 -69
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
- package/skills/caveman/CLAUDE.md +0 -15
- package/skills/caveman/SKILL.md +0 -38
- package/skills/code/CLAUDE.md +0 -17
- package/skills/code/SKILL.md +0 -178
- package/skills/deep-research/CLAUDE.md +0 -17
- package/skills/deep-research/SKILL.md +0 -80
- package/skills/findbugs/CLAUDE.md +0 -20
- package/skills/findbugs/SKILL.md +0 -241
- package/skills/fixbugs/CLAUDE.md +0 -19
- package/skills/fixbugs/SKILL.md +0 -142
- package/skills/gh-paginate/CLAUDE.md +0 -18
- package/skills/gh-paginate/SKILL.md +0 -84
- package/skills/gotcha/CLAUDE.md +0 -33
- package/skills/gotcha/SKILL.md +0 -73
- package/skills/implement/CLAUDE.md +0 -27
- package/skills/implement/SKILL.md +0 -66
- package/skills/implement/scripts/CLAUDE.md +0 -22
- package/skills/implement/scripts/append_note.py +0 -133
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
- package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
- package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
- package/skills/implement/scripts/test_append_note.py +0 -191
- package/skills/log-audit/CLAUDE.md +0 -20
- package/skills/log-audit/SKILL.md +0 -66
- package/skills/log-audit/reference/CLAUDE.md +0 -9
- package/skills/log-audit/reference/charter.md +0 -52
- package/skills/log-audit/scripts/CLAUDE.md +0 -27
- package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
- package/skills/log-audit/scripts/collect_log_window.py +0 -199
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
- package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
- package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
- package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
- package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
- package/skills/logifix/CLAUDE.md +0 -36
- package/skills/logifix/SKILL.md +0 -69
- package/skills/logifix/scripts/CLAUDE.md +0 -16
- package/skills/logifix/scripts/logifix.ps1 +0 -205
- package/skills/monitor-open-prs/CLAUDE.md +0 -34
- package/skills/monitor-open-prs/SKILL.md +0 -88
- package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
- package/skills/monitor-open-prs/test_skill_contract.py +0 -27
- package/skills/post-audit-findings/SKILL.md +0 -80
- package/skills/pr-consistency-audit/CLAUDE.md +0 -34
- package/skills/pr-consistency-audit/SKILL.md +0 -116
- package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
- package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
- package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
- package/skills/pr-review-responder/CLAUDE.md +0 -35
- package/skills/pr-review-responder/EXAMPLES.md +0 -590
- package/skills/pr-review-responder/PRINCIPLES.md +0 -533
- package/skills/pr-review-responder/README.md +0 -168
- package/skills/pr-review-responder/SKILL.md +0 -203
- package/skills/pr-review-responder/TESTING.md +0 -350
- package/skills/pr-scope-resolve/SKILL.md +0 -45
- package/skills/pre-compact/CLAUDE.md +0 -24
- package/skills/pre-compact/SKILL.md +0 -134
- package/skills/qbug/CLAUDE.md +0 -40
- package/skills/qbug/SKILL.md +0 -387
- package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
- package/skills/refine/CLAUDE.md +0 -44
- package/skills/refine/SKILL.md +0 -262
- package/skills/refine/templates/CLAUDE.md +0 -17
- package/skills/refine/templates/implementation-notes-template.html +0 -56
- package/skills/refine/templates/plan-template.md +0 -60
- package/skills/research-mode/CLAUDE.md +0 -35
- package/skills/research-mode/SKILL.md +0 -53
- package/skills/structure-prompt/CLAUDE.md +0 -42
- package/skills/structure-prompt/SKILL.md +0 -44
- package/skills/structure-prompt/reference/CLAUDE.md +0 -28
- package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
- package/skills/structure-prompt/reference/block-classification.md +0 -27
- package/skills/structure-prompt/reference/canonical-case.md +0 -48
- package/skills/structure-prompt/reference/citation-depth.md +0 -70
- package/skills/structure-prompt/reference/cleanup.md +0 -33
- package/skills/structure-prompt/reference/constraints.md +0 -33
- package/skills/structure-prompt/reference/directives.md +0 -37
- package/skills/structure-prompt/reference/examples.md +0 -72
- package/skills/structure-prompt/reference/instantiation.md +0 -51
- package/skills/structure-prompt/reference/output-contract.md +0 -72
- package/skills/structure-prompt/reference/per-category.md +0 -23
- package/skills/structure-prompt/reference/persona.md +0 -38
- package/skills/structure-prompt/reference/research.md +0 -33
- package/skills/structure-prompt/reference/structure.md +0 -28
- package/skills/verified-build/CLAUDE.md +0 -33
- package/skills/verified-build/SKILL.md +0 -33
|
@@ -28,6 +28,7 @@ import pii_prevention_blocker as blocker_module # noqa: E402
|
|
|
28
28
|
from pii_prevention_blocker import ( # noqa: E402
|
|
29
29
|
evaluate,
|
|
30
30
|
evaluate_bash_command,
|
|
31
|
+
evaluate_post_body_texts,
|
|
31
32
|
evaluate_staged_commit,
|
|
32
33
|
evaluate_write_edit_payload,
|
|
33
34
|
extract_git_commit_working_directory,
|
|
@@ -903,3 +904,301 @@ def test_git_exe_dash_c_commit_scans_named_repo_not_cwd(tmp_path: Path) -> None:
|
|
|
903
904
|
assert dash_c_scan is not None
|
|
904
905
|
assert "email" in dash_c_scan
|
|
905
906
|
assert SYNTHETIC_REAL_EMAIL not in dash_c_scan
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _add_repository_origin(repository_root: Path, origin_url: str) -> None:
|
|
910
|
+
subprocess.run(
|
|
911
|
+
["git", "remote", "add", "origin", origin_url],
|
|
912
|
+
cwd=repository_root,
|
|
913
|
+
check=True,
|
|
914
|
+
capture_output=True,
|
|
915
|
+
text=True,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def test_exempt_repository_commit_skips_staged_pii_scan(
|
|
920
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
921
|
+
) -> None:
|
|
922
|
+
repository_root = tmp_path / "repo"
|
|
923
|
+
_init_repo_with_staged_email(repository_root)
|
|
924
|
+
_add_repository_origin(
|
|
925
|
+
repository_root, "https://github.com/ExemptOwner/exempt-repo.git"
|
|
926
|
+
)
|
|
927
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
928
|
+
deny_reason = evaluate_bash_command(
|
|
929
|
+
"git commit -m test",
|
|
930
|
+
working_directory=str(repository_root),
|
|
931
|
+
)
|
|
932
|
+
assert deny_reason is None
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def test_exempt_repository_matches_ssh_origin_url(
|
|
936
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
937
|
+
) -> None:
|
|
938
|
+
repository_root = tmp_path / "repo"
|
|
939
|
+
_init_repo_with_staged_email(repository_root)
|
|
940
|
+
_add_repository_origin(
|
|
941
|
+
repository_root, "git@github.com:ExemptOwner/exempt-repo.git"
|
|
942
|
+
)
|
|
943
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "exemptowner/EXEMPT-REPO")
|
|
944
|
+
deny_reason = evaluate_bash_command(
|
|
945
|
+
"git commit -m test",
|
|
946
|
+
working_directory=str(repository_root),
|
|
947
|
+
)
|
|
948
|
+
assert deny_reason is None
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def test_non_exempt_repository_commit_still_scans_staged_pii(
|
|
952
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
953
|
+
) -> None:
|
|
954
|
+
repository_root = tmp_path / "repo"
|
|
955
|
+
_init_repo_with_staged_email(repository_root)
|
|
956
|
+
_add_repository_origin(
|
|
957
|
+
repository_root, "https://github.com/OtherOwner/other-repo.git"
|
|
958
|
+
)
|
|
959
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
960
|
+
deny_reason = evaluate_bash_command(
|
|
961
|
+
"git commit -m test",
|
|
962
|
+
working_directory=str(repository_root),
|
|
963
|
+
)
|
|
964
|
+
assert deny_reason is not None
|
|
965
|
+
assert "email" in deny_reason
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def test_repository_without_origin_is_not_exempt(
|
|
969
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
970
|
+
) -> None:
|
|
971
|
+
repository_root = tmp_path / "repo"
|
|
972
|
+
_init_repo_with_staged_email(repository_root)
|
|
973
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
974
|
+
deny_reason = evaluate_bash_command(
|
|
975
|
+
"git commit -m test",
|
|
976
|
+
working_directory=str(repository_root),
|
|
977
|
+
)
|
|
978
|
+
assert deny_reason is not None
|
|
979
|
+
assert "email" in deny_reason
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def test_owner_repo_slug_from_https_origin_with_git_suffix() -> None:
|
|
983
|
+
assert (
|
|
984
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
985
|
+
"https://github.com/Owner/repo.git"
|
|
986
|
+
)
|
|
987
|
+
== "owner/repo"
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def test_owner_repo_slug_from_https_origin_without_git_suffix() -> None:
|
|
992
|
+
assert (
|
|
993
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
994
|
+
"https://github.com/Owner/repo"
|
|
995
|
+
)
|
|
996
|
+
== "owner/repo"
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def test_owner_repo_slug_from_scp_ssh_origin() -> None:
|
|
1001
|
+
assert (
|
|
1002
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1003
|
+
"git@github.com:Owner/repo.git"
|
|
1004
|
+
)
|
|
1005
|
+
== "owner/repo"
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def test_owner_repo_slug_from_ssh_scheme_origin() -> None:
|
|
1010
|
+
assert (
|
|
1011
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1012
|
+
"ssh://git@github.com/Owner/repo.git"
|
|
1013
|
+
)
|
|
1014
|
+
== "owner/repo"
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def test_owner_repo_slug_from_trailing_slash_git_suffix() -> None:
|
|
1019
|
+
assert (
|
|
1020
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1021
|
+
"https://github.com/Owner/repo.git/"
|
|
1022
|
+
)
|
|
1023
|
+
== "owner/repo"
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def test_owner_repo_slug_rejects_spoofed_evil_host() -> None:
|
|
1028
|
+
assert (
|
|
1029
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1030
|
+
"https://evil.test/ExemptOwner/exempt-repo.git"
|
|
1031
|
+
)
|
|
1032
|
+
is None
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def test_owner_repo_slug_rejects_notgithub_com_host() -> None:
|
|
1037
|
+
assert (
|
|
1038
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1039
|
+
"https://notgithub.com/Owner/repo.git"
|
|
1040
|
+
)
|
|
1041
|
+
is None
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def test_owner_repo_slug_rejects_github_com_suffix_host() -> None:
|
|
1046
|
+
assert (
|
|
1047
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1048
|
+
"https://github.com.evil.test/Owner/repo.git"
|
|
1049
|
+
)
|
|
1050
|
+
is None
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def test_owner_repo_slug_rejects_invalid_https_port_authority() -> None:
|
|
1055
|
+
assert (
|
|
1056
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1057
|
+
"https://github.com:443.evil.test/Owner/repo.git"
|
|
1058
|
+
)
|
|
1059
|
+
is None
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def test_owner_repo_slug_rejects_invalid_ssh_port_authority() -> None:
|
|
1064
|
+
assert (
|
|
1065
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1066
|
+
"ssh://git@github.com:22.evil.test/Owner/repo.git"
|
|
1067
|
+
)
|
|
1068
|
+
is None
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def test_owner_repo_slug_from_https_origin_with_valid_port() -> None:
|
|
1073
|
+
assert (
|
|
1074
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1075
|
+
"https://github.com:443/Owner/repo.git"
|
|
1076
|
+
)
|
|
1077
|
+
== "owner/repo"
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def test_owner_repo_slug_from_ssh_origin_with_valid_port() -> None:
|
|
1082
|
+
assert (
|
|
1083
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1084
|
+
"ssh://git@github.com:22/Owner/repo.git"
|
|
1085
|
+
)
|
|
1086
|
+
== "owner/repo"
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def test_owner_repo_slug_rejects_path_shaped_origin() -> None:
|
|
1091
|
+
assert (
|
|
1092
|
+
blocker_module._owner_repo_slug_from_origin_url(
|
|
1093
|
+
"C:/repos/ExemptOwner/exempt-repo"
|
|
1094
|
+
)
|
|
1095
|
+
is None
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def test_spoofed_origin_host_still_denies_staged_email(
|
|
1100
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1101
|
+
) -> None:
|
|
1102
|
+
repository_root = tmp_path / "repo"
|
|
1103
|
+
_init_repo_with_staged_email(repository_root)
|
|
1104
|
+
_add_repository_origin(
|
|
1105
|
+
repository_root, "https://evil.test/ExemptOwner/exempt-repo.git"
|
|
1106
|
+
)
|
|
1107
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1108
|
+
deny_reason = evaluate_bash_command(
|
|
1109
|
+
"git commit -m test",
|
|
1110
|
+
working_directory=str(repository_root),
|
|
1111
|
+
)
|
|
1112
|
+
assert deny_reason is not None
|
|
1113
|
+
assert "email" in deny_reason
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def test_invalid_port_authority_origin_still_denies_staged_email(
|
|
1117
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1118
|
+
) -> None:
|
|
1119
|
+
repository_root = tmp_path / "repo"
|
|
1120
|
+
_init_repo_with_staged_email(repository_root)
|
|
1121
|
+
_add_repository_origin(
|
|
1122
|
+
repository_root,
|
|
1123
|
+
"https://github.com:443.evil.test/ExemptOwner/exempt-repo.git",
|
|
1124
|
+
)
|
|
1125
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1126
|
+
deny_reason = evaluate_bash_command(
|
|
1127
|
+
"git commit -m test",
|
|
1128
|
+
working_directory=str(repository_root),
|
|
1129
|
+
)
|
|
1130
|
+
assert deny_reason is not None
|
|
1131
|
+
assert "email" in deny_reason
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def test_path_shaped_origin_still_denies_staged_email(
|
|
1135
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1136
|
+
) -> None:
|
|
1137
|
+
repository_root = tmp_path / "repo"
|
|
1138
|
+
_init_repo_with_staged_email(repository_root)
|
|
1139
|
+
_add_repository_origin(repository_root, "C:/repos/ExemptOwner/exempt-repo")
|
|
1140
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1141
|
+
deny_reason = evaluate_bash_command(
|
|
1142
|
+
"git commit -m test",
|
|
1143
|
+
working_directory=str(repository_root),
|
|
1144
|
+
)
|
|
1145
|
+
assert deny_reason is not None
|
|
1146
|
+
assert "email" in deny_reason
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def test_trailing_slash_git_suffix_origin_still_exempts(
|
|
1150
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1151
|
+
) -> None:
|
|
1152
|
+
repository_root = tmp_path / "repo"
|
|
1153
|
+
_init_repo_with_staged_email(repository_root)
|
|
1154
|
+
_add_repository_origin(
|
|
1155
|
+
repository_root, "https://github.com/ExemptOwner/exempt-repo.git/"
|
|
1156
|
+
)
|
|
1157
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1158
|
+
deny_reason = evaluate_bash_command(
|
|
1159
|
+
"git commit -m test",
|
|
1160
|
+
working_directory=str(repository_root),
|
|
1161
|
+
)
|
|
1162
|
+
assert deny_reason is None
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def test_ssh_scheme_origin_still_exempts(
|
|
1166
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1167
|
+
) -> None:
|
|
1168
|
+
repository_root = tmp_path / "repo"
|
|
1169
|
+
_init_repo_with_staged_email(repository_root)
|
|
1170
|
+
_add_repository_origin(
|
|
1171
|
+
repository_root, "ssh://git@github.com/ExemptOwner/exempt-repo.git"
|
|
1172
|
+
)
|
|
1173
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1174
|
+
deny_reason = evaluate_bash_command(
|
|
1175
|
+
"git commit -m test",
|
|
1176
|
+
working_directory=str(repository_root),
|
|
1177
|
+
)
|
|
1178
|
+
assert deny_reason is None
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def test_write_still_scans_when_repository_is_exempt(
|
|
1182
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1183
|
+
) -> None:
|
|
1184
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1185
|
+
deny_reason = evaluate_write_edit_payload(
|
|
1186
|
+
"Write",
|
|
1187
|
+
{
|
|
1188
|
+
"file_path": "notes.md",
|
|
1189
|
+
"content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
|
|
1190
|
+
},
|
|
1191
|
+
)
|
|
1192
|
+
assert deny_reason is not None
|
|
1193
|
+
assert "email" in deny_reason
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def test_post_body_still_scans_when_repository_is_exempt(
|
|
1197
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1198
|
+
) -> None:
|
|
1199
|
+
monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
|
|
1200
|
+
deny_reason = evaluate_post_body_texts(
|
|
1201
|
+
[f"Reach me at {SYNTHETIC_REAL_EMAIL}"]
|
|
1202
|
+
)
|
|
1203
|
+
assert deny_reason is not None
|
|
1204
|
+
assert "email" in deny_reason
|
|
@@ -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)
|
|
@@ -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
|
+
)
|