claude-dev-env 1.93.0 → 1.94.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/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/code_rules_gate.py +127 -13
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -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 +1 -1
- package/bin/install.mjs +2 -7
- package/bin/install.test.mjs +10 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- 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/pii_scanner.py +26 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_pii_scanner.py +27 -2
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/package.json +1 -1
- 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 +3 -25
- 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 +284 -94
- 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/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/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 +12 -8
- package/skills/orchestrator-refresh/SKILL.md +3 -3
- 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/privacy-hygiene/SKILL.md +8 -7
- 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/usage-pause/SKILL.md +10 -14
- 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-fix-protocol/SKILL.md +0 -65
- 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
|
@@ -17,6 +17,7 @@ _hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
|
17
17
|
if _hooks_directory not in sys.path:
|
|
18
18
|
sys.path.insert(0, _hooks_directory)
|
|
19
19
|
|
|
20
|
+
from hooks_constants.local_identity import nas_host # noqa: E402
|
|
20
21
|
from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
21
22
|
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES,
|
|
22
23
|
ALL_EXACT_LEGAL_NOTICE_BASENAMES,
|
|
@@ -273,9 +274,33 @@ def _find_home_paths(text: str) -> list[PiiFinding]:
|
|
|
273
274
|
return all_findings
|
|
274
275
|
|
|
275
276
|
|
|
277
|
+
def _all_allowlisted_private_ip_addresses() -> frozenset[str]:
|
|
278
|
+
"""Return every private IPv4 address the scanner must leave unflagged.
|
|
279
|
+
|
|
280
|
+
::
|
|
281
|
+
|
|
282
|
+
CLAUDE_NAS_HOST=10.20.30.40 -> static set + {"10.20.30.40"}
|
|
283
|
+
CLAUDE_NAS_HOST=nas.local -> static set only (a name, not an address)
|
|
284
|
+
(env unset, no file) -> static set only
|
|
285
|
+
|
|
286
|
+
The committed set carries no real address. The maintainer's own NAS host
|
|
287
|
+
resolves at scan time from ``CLAUDE_NAS_HOST`` or
|
|
288
|
+
``~/.claude/local-identity.json``, so their ssh traffic and git-ignored
|
|
289
|
+
local config stay unflagged while the repository stays publishable.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
The static allowlist, joined with the locally configured NAS host when
|
|
293
|
+
that host is itself a private IPv4 address.
|
|
294
|
+
"""
|
|
295
|
+
configured_nas_host = nas_host()
|
|
296
|
+
if not _is_private_ipv4_address(configured_nas_host):
|
|
297
|
+
return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES
|
|
298
|
+
return ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES | {configured_nas_host}
|
|
299
|
+
|
|
300
|
+
|
|
276
301
|
def _find_private_ips(text: str) -> list[PiiFinding]:
|
|
277
302
|
all_findings: list[PiiFinding] = []
|
|
278
|
-
allowlisted_addresses =
|
|
303
|
+
allowlisted_addresses = _all_allowlisted_private_ip_addresses()
|
|
279
304
|
for each_match in IPV4_PATTERN.finditer(text):
|
|
280
305
|
address_text = each_match.group(1)
|
|
281
306
|
if address_text in allowlisted_addresses:
|
|
@@ -124,8 +124,8 @@ def test_flags_field_read_only_by_test_module(neutral_root: Path) -> None:
|
|
|
124
124
|
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
125
125
|
"\n"
|
|
126
126
|
"def test_debug_port_default() -> None:\n"
|
|
127
|
-
" cfg = ThemeUpdateConfig(portal_url='x', debug_port=
|
|
128
|
-
" assert cfg.debug_port ==
|
|
127
|
+
" cfg = ThemeUpdateConfig(portal_url='x', debug_port=8471, timeout_seconds=30)\n"
|
|
128
|
+
" assert cfg.debug_port == 8471\n"
|
|
129
129
|
" assert cfg.portal_url == 'x'\n"
|
|
130
130
|
" assert cfg.timeout_seconds == 30\n"
|
|
131
131
|
)
|
|
@@ -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
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
8
10
|
_HOOK_DIR = Path(__file__).parent
|
|
9
11
|
_HOOKS_DIR = _HOOK_DIR.parent
|
|
10
12
|
if str(_HOOK_DIR) not in sys.path:
|
|
@@ -26,6 +28,10 @@ SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
|
|
|
26
28
|
SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
|
|
27
29
|
SYNTHETIC_PRIVATE_IP = "192.168.42.17"
|
|
28
30
|
SYNTHETIC_PUBLIC_IP = "8.8.8.8"
|
|
31
|
+
NAS_HOST_ENVIRONMENT_VARIABLE = "CLAUDE_NAS_HOST"
|
|
32
|
+
SYNTHETIC_NAS_PRIVATE_IP = "10.20.30.40"
|
|
33
|
+
SYNTHETIC_NAS_HOSTNAME = "nas.example.local"
|
|
34
|
+
SYNTHETIC_NAS_SSH_COMMAND = f"ssh -p 2200 operator@{SYNTHETIC_NAS_PRIVATE_IP} uptime"
|
|
29
35
|
|
|
30
36
|
|
|
31
37
|
def test_flags_real_email_and_allows_example_domain() -> None:
|
|
@@ -69,8 +75,27 @@ def test_allows_loopback_unspecified_and_link_local_addresses() -> None:
|
|
|
69
75
|
assert scan_text_for_pii("peer 169.254.10.20") == []
|
|
70
76
|
|
|
71
77
|
|
|
72
|
-
def
|
|
73
|
-
|
|
78
|
+
def test_allows_the_nas_host_resolved_from_local_configuration(
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
) -> None:
|
|
81
|
+
monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
|
|
82
|
+
assert scan_text_for_pii(SYNTHETIC_NAS_SSH_COMMAND) == []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_flags_private_ip_that_is_not_the_configured_nas_host(
|
|
86
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
87
|
+
) -> None:
|
|
88
|
+
monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
|
|
89
|
+
all_lan_hits = scan_text_for_pii(f"peer {SYNTHETIC_PRIVATE_IP}")
|
|
90
|
+
assert [each.matched_text for each in all_lan_hits] == [SYNTHETIC_PRIVATE_IP]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_flags_private_ip_when_the_configured_nas_host_is_a_hostname(
|
|
94
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
95
|
+
) -> None:
|
|
96
|
+
monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_HOSTNAME)
|
|
97
|
+
all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
|
|
98
|
+
assert any(each.category == "private-ip" for each in all_lan_hits)
|
|
74
99
|
|
|
75
100
|
|
|
76
101
|
def test_flags_github_token_aws_key_and_pem_header() -> None:
|
|
@@ -16,8 +16,18 @@ import subprocess
|
|
|
16
16
|
import sys
|
|
17
17
|
|
|
18
18
|
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
19
|
+
_HOOKS_DIR = _HOOK_DIR.parent
|
|
19
20
|
if str(_HOOK_DIR) not in sys.path:
|
|
20
21
|
sys.path.insert(0, str(_HOOK_DIR))
|
|
22
|
+
if str(_HOOKS_DIR) not in sys.path:
|
|
23
|
+
sys.path.insert(0, str(_HOOKS_DIR))
|
|
24
|
+
|
|
25
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
26
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
27
|
+
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
28
|
+
BASH_TOOL_NAME,
|
|
29
|
+
POWERSHELL_TOOL_NAME,
|
|
30
|
+
)
|
|
21
31
|
|
|
22
32
|
guard_spec = importlib.util.spec_from_file_location(
|
|
23
33
|
"verdict_directory_write_blocker",
|
|
@@ -708,18 +718,43 @@ def _pretooluse_commands_for_matcher(matcher_substring: str) -> list[str]:
|
|
|
708
718
|
return matching_commands
|
|
709
719
|
|
|
710
720
|
|
|
711
|
-
|
|
721
|
+
_VERDICT_GUARD_SCRIPT_RELATIVE_PATH = "blocking/verdict_directory_write_blocker.py"
|
|
722
|
+
_BASH_DISPATCHER_BASENAME = "bash_pre_tool_use_dispatcher.py"
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _verdict_guard_hosted_entries() -> list:
|
|
726
|
+
return [
|
|
727
|
+
each_entry
|
|
728
|
+
for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
|
|
729
|
+
if each_entry.script_relative_path == _VERDICT_GUARD_SCRIPT_RELATIVE_PATH
|
|
730
|
+
]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _assert_bash_dispatcher_registered_for_matcher(matcher_substring: str) -> None:
|
|
734
|
+
matching_commands = _pretooluse_commands_for_matcher(matcher_substring)
|
|
712
735
|
assert any(
|
|
713
|
-
|
|
714
|
-
for each_command in
|
|
736
|
+
_BASH_DISPATCHER_BASENAME in each_command
|
|
737
|
+
for each_command in matching_commands
|
|
738
|
+
), (
|
|
739
|
+
f"bash_pre_tool_use_dispatcher.py must be registered on matcher containing "
|
|
740
|
+
f"{matcher_substring!r}; commands={matching_commands!r}"
|
|
715
741
|
)
|
|
716
742
|
|
|
717
743
|
|
|
744
|
+
def test_guard_is_registered_on_bash() -> None:
|
|
745
|
+
_assert_bash_dispatcher_registered_for_matcher("Bash")
|
|
746
|
+
matching_entries = _verdict_guard_hosted_entries()
|
|
747
|
+
assert len(matching_entries) == 1
|
|
748
|
+
assert matching_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
749
|
+
assert BASH_TOOL_NAME in matching_entries[0].applicable_tool_names
|
|
750
|
+
|
|
751
|
+
|
|
718
752
|
def test_guard_is_registered_on_powershell() -> None:
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
753
|
+
_assert_bash_dispatcher_registered_for_matcher("PowerShell")
|
|
754
|
+
matching_entries = _verdict_guard_hosted_entries()
|
|
755
|
+
assert len(matching_entries) == 1
|
|
756
|
+
assert matching_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
|
|
757
|
+
assert POWERSHELL_TOOL_NAME in matching_entries[0].applicable_tool_names
|
|
723
758
|
|
|
724
759
|
|
|
725
760
|
def test_hook_subprocess_imports_real_config_when_parent_holds_shadowing_config(
|
|
@@ -38,7 +38,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
38
38
|
| `hosted_hook_runner.py` | `run_hook_capturing_output()` — shared runner that runs one dispatcher-hosted hook in-process via runpy and returns its captured stdout and crash flag |
|
|
39
39
|
| `inline_tuple_string_magic_constants.py` | Patterns for detecting magic strings in inline tuple literals |
|
|
40
40
|
| `js_conventions_constants.py` | Banned identifier set, boolean-prefix pattern, and declaration/JSDoc patterns for the JavaScript convention checks |
|
|
41
|
-
| `local_identity.py` | Loader for
|
|
41
|
+
| `local_identity.py` | Loader for local machine values: NAS host, ssh user, and ssh port the ssh enforcer guards (`CLAUDE_NAS_*` / `~/.claude/local-identity.json` with placeholder defaults), plus the PII commit-scan exempt-repo slug set (`CLAUDE_PII_EXEMPT_REPOS` / `pii_exempt_repositories`); also composes the ssh enforcer's two deny messages that quote the NAS values |
|
|
42
42
|
| `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
|
|
43
43
|
| `multi_edit_reconstruction.py` | `apply_edits()` / `edits_for_tool()` — shared helpers that reconstruct the post-edit content of an Edit or MultiEdit, imported by the blockers that judge post-edit content |
|
|
44
44
|
| `nas_ssh_binary_enforcer_constants.py` | Bash tool name, ssh-family basenames, OpenSSH binary path suffixes, launcher-wrapper set, shell control-operator tokens and split pattern, leading-assignment and duration patterns, and the batch-mode pattern for the NAS ssh binary enforcer |
|
|
@@ -85,5 +85,5 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
85
85
|
- Hooks import from this package with `from hooks_constants.<module> import <CONSTANT>`.
|
|
86
86
|
- Tests for these modules live beside them as `test_<module>.py`. Run with `python -m pytest hooks_constants/test_<name>.py`.
|
|
87
87
|
- `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, `hosted_hook_runner.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
|
|
88
|
-
- `local_identity.py` is a loader: it reads the environment or `~/.claude/local-identity.json` to resolve
|
|
88
|
+
- `local_identity.py` is a loader: it reads the environment or `~/.claude/local-identity.json` to resolve private NAS values (with committed placeholder defaults) and the PII commit-scan exempt-repo slug set (`CLAUDE_PII_EXEMPT_REPOS` / `pii_exempt_repositories`).
|
|
89
89
|
|
|
@@ -22,8 +22,6 @@ ALL_CONFIG_DIRECTORY_NAMES = frozenset(
|
|
|
22
22
|
"pr_converge_skill_constants",
|
|
23
23
|
"pr_converge_scripts_constants",
|
|
24
24
|
"bugteam_scripts_constants",
|
|
25
|
-
"implement_scripts_constants",
|
|
26
|
-
"log_audit_constants",
|
|
27
25
|
"usage_pause_constants",
|
|
28
26
|
"dev_env_scripts_constants",
|
|
29
27
|
}
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
"""Resolve
|
|
1
|
+
"""Resolve local identity values for NAS ssh and PII exempt-repo hooks.
|
|
2
2
|
|
|
3
|
-
The enforcer
|
|
4
|
-
|
|
5
|
-
Claude home directory
|
|
6
|
-
values
|
|
3
|
+
The enforcer hooks ship into ``~/.claude/`` and cannot read a repository file,
|
|
4
|
+
so they read real machine values from the environment or a git-ignored file in
|
|
5
|
+
the Claude home directory. This module supplies NAS host/user/port, the two
|
|
6
|
+
NAS deny messages that quote those values, and the owner/repo slug set used by
|
|
7
|
+
``pii_exempt_repository_slugs`` (``CLAUDE_PII_EXEMPT_REPOS`` /
|
|
8
|
+
``pii_exempt_repositories``). The committed NAS defaults are placeholders.
|
|
7
9
|
|
|
8
10
|
::
|
|
9
11
|
|
|
10
|
-
CLAUDE_NAS_HOST set to a host
|
|
11
|
-
CLAUDE_NAS_SSH_PORT set to 2200
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
CLAUDE_NAS_HOST set to a host -> nas_host() returns that host
|
|
13
|
+
CLAUDE_NAS_SSH_PORT set to 2200 -> nas_ssh_port() returns 2200
|
|
14
|
+
CLAUDE_PII_EXEMPT_REPOS="Owner/repo" -> {"owner/repo"}
|
|
15
|
+
(env unset, no file) -> nas_host() == "nas.example.local"
|
|
16
|
+
nas_ssh_port() == 22
|
|
17
|
+
pii_exempt_repository_slugs() == frozenset()
|
|
14
18
|
|
|
15
|
-
Each value comes from its
|
|
16
|
-
``~/.claude/local-identity.json``, then the placeholder default.
|
|
19
|
+
Each value comes from its environment variable, then
|
|
20
|
+
``~/.claude/local-identity.json``, then the placeholder default (NAS only).
|
|
17
21
|
"""
|
|
18
22
|
|
|
19
23
|
from __future__ import annotations
|
|
@@ -23,6 +27,9 @@ import os
|
|
|
23
27
|
from pathlib import Path
|
|
24
28
|
|
|
25
29
|
NAS_HOST_ENV_VAR = "CLAUDE_NAS_HOST"
|
|
30
|
+
PII_EXEMPT_REPOS_ENV_VAR = "CLAUDE_PII_EXEMPT_REPOS"
|
|
31
|
+
PII_EXEMPT_REPOS_JSON_KEY = "pii_exempt_repositories"
|
|
32
|
+
PII_EXEMPT_REPOS_SEPARATOR = ","
|
|
26
33
|
NAS_SSH_USER_ENV_VAR = "CLAUDE_NAS_SSH_USER"
|
|
27
34
|
NAS_SSH_PORT_ENV_VAR = "CLAUDE_NAS_SSH_PORT"
|
|
28
35
|
NAS_JSON_KEY = "nas"
|
|
@@ -42,7 +49,7 @@ def _local_identity_file_path() -> Path:
|
|
|
42
49
|
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / LOCAL_IDENTITY_FILE_NAME
|
|
43
50
|
|
|
44
51
|
|
|
45
|
-
def
|
|
52
|
+
def _identity_dictionary_from_local_file() -> dict[str, object]:
|
|
46
53
|
identity_path = _local_identity_file_path()
|
|
47
54
|
if not identity_path.is_file():
|
|
48
55
|
return {}
|
|
@@ -50,12 +57,55 @@ def _nas_section_from_local_file() -> dict[str, object]:
|
|
|
50
57
|
parsed_identity = json.loads(identity_path.read_text(encoding="utf-8"))
|
|
51
58
|
except (OSError, ValueError, UnicodeDecodeError):
|
|
52
59
|
return {}
|
|
53
|
-
if
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
return parsed_identity if isinstance(parsed_identity, dict) else {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _nas_section_from_local_file() -> dict[str, object]:
|
|
64
|
+
nas_section = _identity_dictionary_from_local_file().get(NAS_JSON_KEY, {})
|
|
56
65
|
return nas_section if isinstance(nas_section, dict) else {}
|
|
57
66
|
|
|
58
67
|
|
|
68
|
+
def _normalized_slug_set(all_raw_slugs: list[object]) -> frozenset[str]:
|
|
69
|
+
return frozenset(
|
|
70
|
+
each_slug.strip().lower()
|
|
71
|
+
for each_slug in all_raw_slugs
|
|
72
|
+
if isinstance(each_slug, str) and each_slug.strip()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def pii_exempt_repository_slugs() -> frozenset[str]:
|
|
77
|
+
"""Return owner/repo slugs whose commits skip the staged PII scan.
|
|
78
|
+
|
|
79
|
+
::
|
|
80
|
+
|
|
81
|
+
CLAUDE_PII_EXEMPT_REPOS="Owner/repo-a, Other/b" -> {"owner/repo-a", "other/b"}
|
|
82
|
+
CLAUDE_PII_EXEMPT_REPOS=" , , " + file lists A -> {"a"} (whitespace falls through)
|
|
83
|
+
(env unset, file lists ["Owner/repo-a"]) -> {"owner/repo-a"}
|
|
84
|
+
(env unset, no file) -> frozenset()
|
|
85
|
+
|
|
86
|
+
A non-empty environment variable (comma-separated) wins. When the variable
|
|
87
|
+
is unset or normalizes to no slugs, the ``pii_exempt_repositories`` list in
|
|
88
|
+
the git-ignored ``~/.claude/local-identity.json`` is used. Slugs are
|
|
89
|
+
lowercased so matching is case-insensitive.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Lowercased ``owner/repo`` slugs exempt from staged-commit PII scanning.
|
|
93
|
+
"""
|
|
94
|
+
slugs_from_environment = os.environ.get(PII_EXEMPT_REPOS_ENV_VAR)
|
|
95
|
+
if slugs_from_environment is not None:
|
|
96
|
+
normalized_environment_slugs = _normalized_slug_set(
|
|
97
|
+
list(slugs_from_environment.split(PII_EXEMPT_REPOS_SEPARATOR))
|
|
98
|
+
)
|
|
99
|
+
if normalized_environment_slugs:
|
|
100
|
+
return normalized_environment_slugs
|
|
101
|
+
stored_slugs = _identity_dictionary_from_local_file().get(
|
|
102
|
+
PII_EXEMPT_REPOS_JSON_KEY
|
|
103
|
+
)
|
|
104
|
+
if not isinstance(stored_slugs, list):
|
|
105
|
+
return frozenset()
|
|
106
|
+
return _normalized_slug_set(stored_slugs)
|
|
107
|
+
|
|
108
|
+
|
|
59
109
|
def nas_host() -> str:
|
|
60
110
|
"""Return the NAS host the ssh enforcer guards.
|
|
61
111
|
|
|
@@ -169,11 +169,7 @@ ALL_PLACEHOLDER_HOME_USERNAMES: frozenset[str] = frozenset(
|
|
|
169
169
|
}
|
|
170
170
|
)
|
|
171
171
|
|
|
172
|
-
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES: frozenset[str] = frozenset(
|
|
173
|
-
{
|
|
174
|
-
"192.168.1.100",
|
|
175
|
-
}
|
|
176
|
-
)
|
|
172
|
+
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES: frozenset[str] = frozenset()
|
|
177
173
|
|
|
178
174
|
MAXIMUM_FINDINGS_PER_SCAN: int = 12
|
|
179
175
|
MAXIMUM_STAGED_FILE_BYTES: int = 1_000_000
|
|
@@ -191,6 +187,22 @@ ALL_STAGED_FILES_COMMAND: tuple[str, ...] = (
|
|
|
191
187
|
ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX: tuple[str, ...] = ("git", "show")
|
|
192
188
|
STAGED_BLOB_PREFIX: str = ":"
|
|
193
189
|
|
|
190
|
+
ALL_GIT_ORIGIN_URL_COMMAND: tuple[str, ...] = (
|
|
191
|
+
"git",
|
|
192
|
+
"config",
|
|
193
|
+
"--get",
|
|
194
|
+
"remote.origin.url",
|
|
195
|
+
)
|
|
196
|
+
GIT_URL_SUFFIX: str = ".git"
|
|
197
|
+
GITHUB_COM_HOST: str = "github.com"
|
|
198
|
+
ALL_NETWORK_GIT_URL_SCHEMES: frozenset[str] = frozenset({"http", "https", "ssh"})
|
|
199
|
+
SCP_STYLE_PATH_SEPARATOR: str = ":"
|
|
200
|
+
USERINFO_HOST_SEPARATOR: str = "@"
|
|
201
|
+
URL_SCHEME_SEPARATOR: str = "://"
|
|
202
|
+
WINDOWS_PATH_SEPARATOR: str = "\\"
|
|
203
|
+
POSIX_PATH_SEPARATOR: str = "/"
|
|
204
|
+
MINIMUM_OWNER_REPO_SEGMENT_COUNT: int = 2
|
|
205
|
+
|
|
194
206
|
BODY_FILE_ENCODING: str = "utf-8"
|
|
195
207
|
NULL_BYTE_MARKER: bytes = b"\x00"
|
|
196
208
|
MESSAGE_LINE_SEPARATOR: str = "\n"
|
|
@@ -269,9 +281,9 @@ CORRECTIVE_MESSAGE_HEADER: str = (
|
|
|
269
281
|
CORRECTIVE_MESSAGE_FOOTER: str = (
|
|
270
282
|
"Remediate: replace with placeholders (user@example.com, C:/Users/example/), "
|
|
271
283
|
"move secrets to an env or secret store, and run the privacy-hygiene skill "
|
|
272
|
-
"for a full sweep.
|
|
273
|
-
"
|
|
274
|
-
"
|
|
284
|
+
"for a full sweep. Your own NAS host is allowlisted at scan time from "
|
|
285
|
+
"CLAUDE_NAS_HOST or ~/.claude/local-identity.json, so set it there rather "
|
|
286
|
+
"than committing the address."
|
|
275
287
|
)
|
|
276
288
|
|
|
277
289
|
FINDING_LINE_TEMPLATE: str = " [{category}] {preview}"
|