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
|
@@ -10,18 +10,22 @@ Surfaces guarded:
|
|
|
10
10
|
staged file contents about to become history. Commit message bodies are out
|
|
11
11
|
of scope; only staged blob text is scanned.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
Payload and post-body scanning live in ``pii_payload_scan``; git-commit command
|
|
14
|
+
detection lives in ``pii_commit_command``; both re-export through this entry
|
|
15
|
+
module so the hooks.json wiring and every importer resolve unchanged.
|
|
16
|
+
|
|
17
|
+
A repository named in ``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories``
|
|
18
|
+
list in ``~/.claude/local-identity.json`` skips the staged-commit scan; a
|
|
19
|
+
repository without a readable origin remote is never exempt.
|
|
15
20
|
"""
|
|
16
21
|
|
|
17
22
|
from __future__ import annotations
|
|
18
23
|
|
|
19
24
|
import json
|
|
20
|
-
import re
|
|
21
|
-
import shlex
|
|
22
25
|
import subprocess
|
|
23
26
|
import sys
|
|
24
27
|
from pathlib import Path
|
|
28
|
+
from urllib.parse import urlparse
|
|
25
29
|
|
|
26
30
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
27
31
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -30,60 +34,44 @@ if _blocking_directory not in sys.path:
|
|
|
30
34
|
if _hooks_directory not in sys.path:
|
|
31
35
|
sys.path.insert(0, _hooks_directory)
|
|
32
36
|
|
|
33
|
-
from block_main_commit import
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
from block_main_commit import resolve_directory # noqa: E402
|
|
38
|
+
from pii_commit_command import ( # noqa: E402
|
|
39
|
+
extract_git_commit_working_directory,
|
|
40
|
+
is_git_commit_shell_command,
|
|
36
41
|
)
|
|
37
|
-
from
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
from pii_payload_scan import ( # noqa: E402
|
|
43
|
+
build_deny_reason,
|
|
44
|
+
evaluate_post_body_texts,
|
|
45
|
+
evaluate_write_edit_payload,
|
|
40
46
|
)
|
|
47
|
+
from pii_scanner import is_path_exempt_from_pii_scan, scan_text_for_pii # noqa: E402
|
|
48
|
+
from precommit_code_rules_gate import resolve_repository_root # noqa: E402
|
|
41
49
|
from volatile_path_in_post_blocker import ( # noqa: E402
|
|
42
50
|
extract_gh_post_body_texts_for_privacy_gate,
|
|
43
51
|
extract_mcp_body_texts,
|
|
44
52
|
)
|
|
45
53
|
|
|
46
54
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
47
|
-
from hooks_constants.
|
|
55
|
+
from hooks_constants.local_identity import pii_exempt_repository_slugs # noqa: E402
|
|
48
56
|
from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
ALL_GIT_BINARY_BASENAMES,
|
|
52
|
-
ALL_LEADING_SKIPPABLE_COMMAND_TOKENS,
|
|
53
|
-
ALL_ONE_OPERAND_WRAPPER_TOKENS,
|
|
54
|
-
ALL_SHELL_COMMAND_SEPARATOR_TOKENS,
|
|
55
|
-
ALL_SHELL_INTERPRETER_BASENAMES,
|
|
56
|
-
ALL_SHELL_QUOTE_CHARACTERS,
|
|
57
|
+
ALL_GIT_ORIGIN_URL_COMMAND,
|
|
58
|
+
ALL_NETWORK_GIT_URL_SCHEMES,
|
|
57
59
|
ALL_SHELL_TOOL_NAMES,
|
|
58
60
|
ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
|
|
59
61
|
ALL_STAGED_FILES_COMMAND,
|
|
60
|
-
ALL_VALUE_TAKING_GIT_OPTIONS,
|
|
61
62
|
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
62
63
|
BODY_FILE_ENCODING,
|
|
63
|
-
CORRECTIVE_MESSAGE_FOOTER,
|
|
64
|
-
CORRECTIVE_MESSAGE_HEADER,
|
|
65
|
-
DOUBLE_DASH_OPTION_PREFIX,
|
|
66
|
-
EDIT_TOOL_NAME,
|
|
67
|
-
ENVIRONMENT_ASSIGNMENT_PATTERN,
|
|
68
|
-
FINDING_LINE_TEMPLATE,
|
|
69
64
|
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
GIT_WORKING_DIRECTORY_OPTION,
|
|
65
|
+
GIT_URL_SUFFIX,
|
|
66
|
+
GITHUB_COM_HOST,
|
|
73
67
|
HOOK_SCRIPT_BASENAME,
|
|
74
|
-
INLINE_COMMAND_FLAG_CLUSTER_CHARACTER,
|
|
75
|
-
INLINE_COMMAND_TOKEN_JOINER,
|
|
76
|
-
LINE_CONTINUATION_PATTERN,
|
|
77
68
|
MAXIMUM_STAGED_FILE_BYTES,
|
|
78
69
|
MCP_GITHUB_TOOL_PREFIX,
|
|
79
|
-
|
|
80
|
-
MULTI_EDIT_TOOL_NAME,
|
|
70
|
+
MINIMUM_OWNER_REPO_SEGMENT_COUNT,
|
|
81
71
|
NULL_BYTE_MARKER,
|
|
82
|
-
|
|
83
|
-
POWERSHELL_LINE_CONTINUATION_PATTERN,
|
|
72
|
+
POSIX_PATH_SEPARATOR,
|
|
84
73
|
REPOSITORY_ROOT_UNRESOLVED_REASON,
|
|
85
|
-
|
|
86
|
-
SINGLE_DASH_OPTION_PREFIX,
|
|
74
|
+
SCP_STYLE_PATH_SEPARATOR,
|
|
87
75
|
STAGED_BLOB_PREFIX,
|
|
88
76
|
STAGED_BLOB_REASON_DECODE_FAILED,
|
|
89
77
|
STAGED_BLOB_REASON_GIT_SHOW_FAILED,
|
|
@@ -91,109 +79,15 @@ from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
|
91
79
|
STAGED_BLOB_REASON_OVERSIZED,
|
|
92
80
|
STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE,
|
|
93
81
|
STAGED_LIST_FAILURE_REASON,
|
|
94
|
-
|
|
95
|
-
|
|
82
|
+
URL_SCHEME_SEPARATOR,
|
|
83
|
+
USERINFO_HOST_SEPARATOR,
|
|
84
|
+
WINDOWS_PATH_SEPARATOR,
|
|
96
85
|
)
|
|
97
86
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
98
87
|
read_hook_input_dictionary_from_stdin,
|
|
99
88
|
)
|
|
100
89
|
|
|
101
90
|
|
|
102
|
-
def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
|
|
103
|
-
"""Return the deny message listing each finding for *gate_surface*.
|
|
104
|
-
|
|
105
|
-
Args:
|
|
106
|
-
all_findings: Findings returned by ``scan_text_for_pii``.
|
|
107
|
-
gate_surface: Human-readable surface (write, post body, staged commit).
|
|
108
|
-
|
|
109
|
-
Returns:
|
|
110
|
-
Multi-line deny reason for ``permissionDecisionReason``.
|
|
111
|
-
"""
|
|
112
|
-
all_lines = [
|
|
113
|
-
CORRECTIVE_MESSAGE_HEADER,
|
|
114
|
-
f"Surface: {gate_surface}",
|
|
115
|
-
]
|
|
116
|
-
for each_finding in all_findings:
|
|
117
|
-
all_lines.append(
|
|
118
|
-
FINDING_LINE_TEMPLATE.format(
|
|
119
|
-
category=each_finding.category,
|
|
120
|
-
preview=each_finding.preview,
|
|
121
|
-
)
|
|
122
|
-
)
|
|
123
|
-
all_lines.append(CORRECTIVE_MESSAGE_FOOTER)
|
|
124
|
-
message_line_separator = MESSAGE_LINE_SEPARATOR
|
|
125
|
-
return message_line_separator.join(all_lines)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _collect_write_edit_texts(
|
|
129
|
-
tool_name: str, all_tool_input: dict[str, object]
|
|
130
|
-
) -> tuple[str, list[str]]:
|
|
131
|
-
raw_file_path = all_tool_input.get("file_path", "")
|
|
132
|
-
file_path = raw_file_path if isinstance(raw_file_path, str) else ""
|
|
133
|
-
if is_path_exempt_from_pii_scan(file_path):
|
|
134
|
-
return file_path, []
|
|
135
|
-
if tool_name == WRITE_TOOL_NAME:
|
|
136
|
-
write_content = all_tool_input.get("content", "")
|
|
137
|
-
if isinstance(write_content, str) and write_content:
|
|
138
|
-
return file_path, [write_content]
|
|
139
|
-
return file_path, []
|
|
140
|
-
if tool_name in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
|
|
141
|
-
all_texts: list[str] = []
|
|
142
|
-
for each_edit in edits_for_tool(tool_name, all_tool_input):
|
|
143
|
-
if not isinstance(each_edit, dict):
|
|
144
|
-
continue
|
|
145
|
-
new_string = each_edit.get("new_string", "")
|
|
146
|
-
if isinstance(new_string, str) and new_string:
|
|
147
|
-
all_texts.append(new_string)
|
|
148
|
-
return file_path, all_texts
|
|
149
|
-
return file_path, []
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _first_findings_in_texts(all_texts: list[str]) -> list[PiiFinding]:
|
|
153
|
-
for each_text in all_texts:
|
|
154
|
-
all_findings = scan_text_for_pii(each_text)
|
|
155
|
-
if all_findings:
|
|
156
|
-
return all_findings
|
|
157
|
-
return []
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def evaluate_write_edit_payload(
|
|
161
|
-
tool_name: str, all_tool_input: dict[str, object]
|
|
162
|
-
) -> str | None:
|
|
163
|
-
"""Return a deny reason when Write/Edit/MultiEdit content carries PII.
|
|
164
|
-
|
|
165
|
-
Args:
|
|
166
|
-
tool_name: The intercepted tool name.
|
|
167
|
-
all_tool_input: The tool input mapping.
|
|
168
|
-
|
|
169
|
-
Returns:
|
|
170
|
-
Deny reason text, or None when the write is clean or out of scope.
|
|
171
|
-
"""
|
|
172
|
-
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
173
|
-
return None
|
|
174
|
-
file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
|
|
175
|
-
all_findings = _first_findings_in_texts(all_texts)
|
|
176
|
-
if not all_findings:
|
|
177
|
-
return None
|
|
178
|
-
gate_surface = f"file write ({file_path or 'unknown path'})"
|
|
179
|
-
return build_deny_reason(all_findings, gate_surface)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def evaluate_post_body_texts(all_body_texts: list[str]) -> str | None:
|
|
183
|
-
"""Return a deny reason when any durable post body carries PII.
|
|
184
|
-
|
|
185
|
-
Args:
|
|
186
|
-
all_body_texts: Body strings extracted from a gh or MCP post tool.
|
|
187
|
-
|
|
188
|
-
Returns:
|
|
189
|
-
Deny reason text, or None when every body is clean.
|
|
190
|
-
"""
|
|
191
|
-
all_findings = _first_findings_in_texts(all_body_texts)
|
|
192
|
-
if not all_findings:
|
|
193
|
-
return None
|
|
194
|
-
return build_deny_reason(all_findings, "durable GitHub post body")
|
|
195
|
-
|
|
196
|
-
|
|
197
91
|
def list_staged_file_paths(
|
|
198
92
|
repository_root: Path,
|
|
199
93
|
) -> tuple[list[str] | None, str | None]:
|
|
@@ -314,359 +208,165 @@ def evaluate_staged_commit(
|
|
|
314
208
|
return None
|
|
315
209
|
|
|
316
210
|
|
|
317
|
-
def
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _token_is_git_binary(token_text: str) -> bool:
|
|
327
|
-
return _token_basename_lower(token_text) in ALL_GIT_BINARY_BASENAMES
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def _token_is_shell_interpreter(token_text: str) -> bool:
|
|
331
|
-
return _token_basename_lower(token_text) in ALL_SHELL_INTERPRETER_BASENAMES
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _token_is_bash_family_interpreter(token_text: str) -> bool:
|
|
335
|
-
return _token_basename_lower(token_text) in ALL_BASH_FAMILY_INTERPRETER_BASENAMES
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _token_is_subshell_group_open(token_text: str) -> bool:
|
|
339
|
-
return token_text == SUBSHELL_GROUP_OPEN_TOKEN
|
|
211
|
+
def _strip_trailing_path_separators(origin_url: str) -> str:
|
|
212
|
+
stripped_url = origin_url
|
|
213
|
+
while stripped_url and stripped_url[-1] in (
|
|
214
|
+
POSIX_PATH_SEPARATOR,
|
|
215
|
+
WINDOWS_PATH_SEPARATOR,
|
|
216
|
+
):
|
|
217
|
+
stripped_url = stripped_url[:-1]
|
|
218
|
+
return stripped_url
|
|
340
219
|
|
|
341
220
|
|
|
342
|
-
def
|
|
343
|
-
if
|
|
344
|
-
return
|
|
345
|
-
return
|
|
221
|
+
def _strip_trailing_git_suffix(origin_url: str) -> str:
|
|
222
|
+
if origin_url.lower().endswith(GIT_URL_SUFFIX):
|
|
223
|
+
return origin_url[: -len(GIT_URL_SUFFIX)]
|
|
224
|
+
return origin_url
|
|
346
225
|
|
|
347
226
|
|
|
348
|
-
def
|
|
349
|
-
|
|
350
|
-
option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
|
|
351
|
-
while token_index < len(all_following_tokens):
|
|
352
|
-
each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
|
|
353
|
-
option_name = each_token
|
|
354
|
-
has_attached_value = False
|
|
355
|
-
if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
|
|
356
|
-
option_name, _, _attached_value = each_token.partition("=")
|
|
357
|
-
has_attached_value = True
|
|
358
|
-
if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
|
|
359
|
-
if has_attached_value:
|
|
360
|
-
token_index += 1
|
|
361
|
-
else:
|
|
362
|
-
token_index += option_with_value_step
|
|
363
|
-
continue
|
|
364
|
-
if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
|
|
365
|
-
token_index += 1
|
|
366
|
-
continue
|
|
367
|
-
return each_token.lower() == GIT_COMMIT_SUBCOMMAND
|
|
368
|
-
return False
|
|
227
|
+
def _normalized_origin_url(origin_url: str) -> str:
|
|
228
|
+
return _strip_trailing_git_suffix(_strip_trailing_path_separators(origin_url))
|
|
369
229
|
|
|
370
230
|
|
|
371
|
-
def
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
all_segments[-1].append(each_token)
|
|
379
|
-
return all_segments
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
def _tokenize_shell_command(shell_command_piece: str) -> list[str] | None:
|
|
383
|
-
lexer = shlex.shlex(shell_command_piece, posix=True, punctuation_chars=True)
|
|
384
|
-
lexer.whitespace_split = True
|
|
385
|
-
lexer.escape = ""
|
|
386
|
-
lexer.commenters = ""
|
|
231
|
+
def _host_and_path_from_scheme_url(origin_url: str) -> tuple[str, str] | None:
|
|
232
|
+
parsed_url = urlparse(origin_url)
|
|
233
|
+
if parsed_url.scheme not in ALL_NETWORK_GIT_URL_SCHEMES:
|
|
234
|
+
return None
|
|
235
|
+
maybe_host = parsed_url.hostname
|
|
236
|
+
if maybe_host is None:
|
|
237
|
+
return None
|
|
387
238
|
try:
|
|
388
|
-
|
|
239
|
+
parsed_url.port
|
|
389
240
|
except ValueError:
|
|
390
241
|
return None
|
|
242
|
+
repository_path = parsed_url.path.replace(
|
|
243
|
+
WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR
|
|
244
|
+
).lstrip(POSIX_PATH_SEPARATOR)
|
|
245
|
+
return maybe_host.lower(), repository_path
|
|
391
246
|
|
|
392
247
|
|
|
393
|
-
def
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
current_characters: list[str] = []
|
|
411
|
-
active_quote_character = ""
|
|
412
|
-
for each_character in shell_command:
|
|
413
|
-
if active_quote_character:
|
|
414
|
-
if each_character == active_quote_character:
|
|
415
|
-
active_quote_character = ""
|
|
416
|
-
current_characters.append(each_character)
|
|
417
|
-
continue
|
|
418
|
-
if each_character in ALL_SHELL_QUOTE_CHARACTERS:
|
|
419
|
-
active_quote_character = each_character
|
|
420
|
-
current_characters.append(each_character)
|
|
421
|
-
continue
|
|
422
|
-
if each_character in ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS:
|
|
423
|
-
all_pieces.append("".join(current_characters))
|
|
424
|
-
current_characters = []
|
|
425
|
-
continue
|
|
426
|
-
current_characters.append(each_character)
|
|
427
|
-
all_pieces.append("".join(current_characters))
|
|
428
|
-
return all_pieces
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
def _all_command_segments(shell_command: str) -> list[list[str]]:
|
|
432
|
-
collapsed_command = LINE_CONTINUATION_PATTERN.sub("", shell_command)
|
|
433
|
-
collapsed_command = POWERSHELL_LINE_CONTINUATION_PATTERN.sub("", collapsed_command)
|
|
434
|
-
all_segments: list[list[str]] = []
|
|
435
|
-
for each_piece in _split_on_unquoted_newlines(collapsed_command):
|
|
436
|
-
all_segments.extend(_segments_for_piece(each_piece))
|
|
437
|
-
return all_segments
|
|
438
|
-
|
|
248
|
+
def _host_and_path_from_scp_url(origin_url: str) -> tuple[str, str] | None:
|
|
249
|
+
if USERINFO_HOST_SEPARATOR not in origin_url:
|
|
250
|
+
return None
|
|
251
|
+
userinfo_and_host, separator, repository_path = origin_url.partition(
|
|
252
|
+
SCP_STYLE_PATH_SEPARATOR
|
|
253
|
+
)
|
|
254
|
+
if not separator or USERINFO_HOST_SEPARATOR not in userinfo_and_host:
|
|
255
|
+
return None
|
|
256
|
+
if not repository_path:
|
|
257
|
+
return None
|
|
258
|
+
host = userinfo_and_host.rsplit(USERINFO_HOST_SEPARATOR, maxsplit=1)[-1]
|
|
259
|
+
if not host:
|
|
260
|
+
return None
|
|
261
|
+
normalized_path = repository_path.replace(
|
|
262
|
+
WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR
|
|
263
|
+
)
|
|
264
|
+
return host.lower(), normalized_path
|
|
439
265
|
|
|
440
|
-
def _token_is_leading_skip_target(token_text: str) -> bool:
|
|
441
|
-
return _token_is_git_binary(token_text) or _token_is_shell_interpreter(token_text)
|
|
442
266
|
|
|
267
|
+
def _host_and_repository_path_from_origin_url(
|
|
268
|
+
origin_url: str,
|
|
269
|
+
) -> tuple[str, str] | None:
|
|
270
|
+
normalized_url = _normalized_origin_url(origin_url)
|
|
271
|
+
if URL_SCHEME_SEPARATOR in normalized_url:
|
|
272
|
+
return _host_and_path_from_scheme_url(normalized_url)
|
|
273
|
+
return _host_and_path_from_scp_url(normalized_url)
|
|
443
274
|
|
|
444
|
-
def _token_is_wrapper_option_flag(token_text: str) -> bool:
|
|
445
|
-
return token_text.startswith(SINGLE_DASH_OPTION_PREFIX)
|
|
446
275
|
|
|
276
|
+
def _owner_repo_slug_from_path(repository_path: str) -> str | None:
|
|
277
|
+
all_segments = [
|
|
278
|
+
each_segment
|
|
279
|
+
for each_segment in repository_path.split(POSIX_PATH_SEPARATOR)
|
|
280
|
+
if each_segment
|
|
281
|
+
]
|
|
282
|
+
if len(all_segments) < MINIMUM_OWNER_REPO_SEGMENT_COUNT:
|
|
283
|
+
return None
|
|
284
|
+
owner_name = all_segments[0]
|
|
285
|
+
repository_name = all_segments[1]
|
|
286
|
+
return POSIX_PATH_SEPARATOR.join((owner_name, repository_name)).lower()
|
|
447
287
|
|
|
448
|
-
def _wrapper_leading_operand_count(token_text: str) -> int:
|
|
449
|
-
if _token_basename_lower(token_text) in ALL_ONE_OPERAND_WRAPPER_TOKENS:
|
|
450
|
-
return 1
|
|
451
|
-
return 0
|
|
452
288
|
|
|
289
|
+
def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
|
|
290
|
+
"""Return the lowercased owner/repo slug from a github.com origin URL.
|
|
453
291
|
|
|
454
|
-
|
|
455
|
-
if value_index >= len(all_segment_tokens):
|
|
456
|
-
return False
|
|
457
|
-
if not _token_is_leading_skip_target(all_segment_tokens[value_index]):
|
|
458
|
-
return True
|
|
459
|
-
return _later_token_is_leading_skip_target(all_segment_tokens, value_index + 1)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def _later_token_is_leading_skip_target(
|
|
463
|
-
all_segment_tokens: list[str], search_start_index: int
|
|
464
|
-
) -> bool:
|
|
465
|
-
for each_token in all_segment_tokens[search_start_index:]:
|
|
466
|
-
if _token_is_leading_skip_target(each_token):
|
|
467
|
-
return True
|
|
468
|
-
return False
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
def _skip_leading_noop_tokens(all_segment_tokens: list[str]) -> int:
|
|
472
|
-
token_index = 0
|
|
473
|
-
has_skipped_wrapper_prefix = False
|
|
474
|
-
pending_operand_budget = 0
|
|
475
|
-
while token_index < len(all_segment_tokens):
|
|
476
|
-
each_token = all_segment_tokens[token_index]
|
|
477
|
-
if _token_is_subshell_group_open(each_token):
|
|
478
|
-
token_index += 1
|
|
479
|
-
continue
|
|
480
|
-
if _token_is_skippable_prefix(each_token):
|
|
481
|
-
has_skipped_wrapper_prefix = True
|
|
482
|
-
pending_operand_budget += _wrapper_leading_operand_count(each_token)
|
|
483
|
-
token_index += 1
|
|
484
|
-
continue
|
|
485
|
-
if not has_skipped_wrapper_prefix:
|
|
486
|
-
break
|
|
487
|
-
if _token_is_leading_skip_target(each_token):
|
|
488
|
-
break
|
|
489
|
-
if _token_is_wrapper_option_flag(each_token):
|
|
490
|
-
token_index += 1
|
|
491
|
-
if _flag_value_token_follows(all_segment_tokens, token_index):
|
|
492
|
-
token_index += 1
|
|
493
|
-
continue
|
|
494
|
-
if pending_operand_budget > 0:
|
|
495
|
-
pending_operand_budget -= 1
|
|
496
|
-
token_index += 1
|
|
497
|
-
continue
|
|
498
|
-
break
|
|
499
|
-
return token_index
|
|
292
|
+
::
|
|
500
293
|
|
|
294
|
+
https://github.com/Owner/Repo.git -> owner/repo
|
|
295
|
+
https://github.com/Owner/Repo -> owner/repo
|
|
296
|
+
https://github.com:443/Owner/Repo.git -> owner/repo
|
|
297
|
+
ssh://git@github.com:22/Owner/Repo.git -> owner/repo
|
|
298
|
+
git@github.com:Owner/Repo.git -> owner/repo
|
|
299
|
+
ssh://git@github.com/Owner/Repo.git -> owner/repo
|
|
300
|
+
https://github.com/Owner/Repo.git/ -> owner/repo
|
|
301
|
+
https://evil.test/Owner/Repo.git -> None
|
|
302
|
+
https://github.com:443.evil.test/Owner/Repo.git -> None
|
|
303
|
+
ssh://git@github.com:22.evil.test/Owner/Repo.git -> None
|
|
304
|
+
C:/repos/Owner/Repo -> None
|
|
501
305
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return POWERSHELL_INLINE_COMMAND_FLAG.startswith(lowered_token)
|
|
306
|
+
Accepts only the exact host ``github.com`` (case-insensitive). Spoof hosts,
|
|
307
|
+
unparseable port authorities, and path-shaped origins return None so the
|
|
308
|
+
repository is never exempt.
|
|
506
309
|
|
|
310
|
+
Args:
|
|
311
|
+
origin_url: The ``remote.origin.url`` value, https or ssh form.
|
|
507
312
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if not interpreter_allows_short_flag_cluster:
|
|
521
|
-
return False
|
|
522
|
-
clustered_flag_characters = lowered_token[len(SINGLE_DASH_OPTION_PREFIX) :]
|
|
523
|
-
return INLINE_COMMAND_FLAG_CLUSTER_CHARACTER in clustered_flag_characters
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
def _interpreter_inline_command_invokes_commit(
|
|
527
|
-
interpreter_token: str, all_following_tokens: list[str]
|
|
528
|
-
) -> bool:
|
|
529
|
-
allows_short_flag_cluster = _token_is_bash_family_interpreter(interpreter_token)
|
|
530
|
-
token_index = 0
|
|
531
|
-
while token_index < len(all_following_tokens):
|
|
532
|
-
each_token = all_following_tokens[token_index]
|
|
533
|
-
if _token_is_interpreter_inline_command_flag(
|
|
534
|
-
each_token, allows_short_flag_cluster
|
|
535
|
-
):
|
|
536
|
-
argument_index = token_index + 1
|
|
537
|
-
if argument_index >= len(all_following_tokens):
|
|
538
|
-
return False
|
|
539
|
-
inline_command = INLINE_COMMAND_TOKEN_JOINER.join(
|
|
540
|
-
all_following_tokens[argument_index:]
|
|
541
|
-
)
|
|
542
|
-
return is_git_commit_shell_command(inline_command)
|
|
543
|
-
token_index += 1
|
|
544
|
-
return False
|
|
313
|
+
Returns:
|
|
314
|
+
The ``owner/repo`` slug in lowercase, or None when the host is not
|
|
315
|
+
exactly ``github.com``, the port authority is unparseable, or the URL
|
|
316
|
+
carries no owner/repo tail.
|
|
317
|
+
"""
|
|
318
|
+
maybe_host_and_path = _host_and_repository_path_from_origin_url(origin_url)
|
|
319
|
+
if maybe_host_and_path is None:
|
|
320
|
+
return None
|
|
321
|
+
host, repository_path = maybe_host_and_path
|
|
322
|
+
if host != GITHUB_COM_HOST:
|
|
323
|
+
return None
|
|
324
|
+
return _owner_repo_slug_from_path(repository_path)
|
|
545
325
|
|
|
546
326
|
|
|
547
|
-
def
|
|
548
|
-
|
|
549
|
-
if command_index >= len(all_segment_tokens):
|
|
550
|
-
return False
|
|
551
|
-
all_following_tokens = all_segment_tokens[command_index + 1 :]
|
|
552
|
-
command_token = all_segment_tokens[command_index]
|
|
553
|
-
if _token_is_shell_interpreter(command_token):
|
|
554
|
-
return _interpreter_inline_command_invokes_commit(
|
|
555
|
-
command_token, all_following_tokens
|
|
556
|
-
)
|
|
557
|
-
if not _token_is_git_binary(command_token):
|
|
558
|
-
return False
|
|
559
|
-
return _following_tokens_invoke_commit(all_following_tokens)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def is_git_commit_shell_command(shell_command: str) -> bool:
|
|
563
|
-
"""Report whether *shell_command* invokes git commit (token-aware).
|
|
564
|
-
|
|
565
|
-
Each segment is read past its leading noise to the real command word::
|
|
566
|
-
|
|
567
|
-
sudo git commit -m x -> skip the wrapper, then match commit
|
|
568
|
-
nice -n 10 git commit -> skip the wrapper and its flag value
|
|
569
|
-
then git commit -m x -> skip the keyword, then match commit
|
|
570
|
-
build & git commit -m x -> split on the background operator
|
|
571
|
-
bash -cx "git commit" -> unwrap the bash cluster
|
|
572
|
-
pwsh -ExecutionPolicy Bypass -Command ... -> skip past the pwsh flag
|
|
573
|
-
(git commit -m x) -> step over the group open
|
|
574
|
-
|
|
575
|
-
Skipped leading tokens: a subshell-group open ``(``, env-assignments, shell
|
|
576
|
-
keywords (then, do, else, elif), and wrapper commands (sudo, env, time,
|
|
577
|
-
nice, xargs, command, stdbuf) together with each wrapper's own option flags,
|
|
578
|
-
flag values, and the single leading operand that timeout and flock take
|
|
579
|
-
before their command. A non-wrapper command word between a wrapper and a
|
|
580
|
-
later git commit stops the scan, so the wrapper's own payload command is
|
|
581
|
-
read rather than the trailing git token. Segments split on unquoted control
|
|
582
|
-
separators (including
|
|
583
|
-
a lone ``&`` background operator) and newlines, and the git binary may be
|
|
584
|
-
path-prefixed and carry global flags (no-verify, config, and
|
|
585
|
-
working-directory) before its subcommand. A shell interpreter is unwrapped
|
|
586
|
-
at its inline-command flag by interpreter family: bash and sh take an
|
|
587
|
-
isolated ``-c`` or any short-flag cluster carrying ``c`` (``-lc``, ``-cx``),
|
|
588
|
-
and PowerShell (pwsh, powershell) takes only ``-Command`` or ``-c``, so a
|
|
589
|
-
leading pwsh flag whose name merely contains ``c`` (``-ExecutionPolicy``,
|
|
590
|
-
``-NonInteractive``) is stepped over until the real ``-Command`` is reached.
|
|
591
|
-
The inline command's remaining tokens rejoin into one string, so an
|
|
592
|
-
unquoted multi-token command is read whole.
|
|
327
|
+
def _repository_origin_slug(repository_root: Path) -> str | None:
|
|
328
|
+
"""Return the lowercased owner/repo slug of the origin remote, or None.
|
|
593
329
|
|
|
594
330
|
Args:
|
|
595
|
-
|
|
331
|
+
repository_root: Repository whose origin remote identifies it.
|
|
596
332
|
|
|
597
333
|
Returns:
|
|
598
|
-
|
|
334
|
+
The ``owner/repo`` slug in lowercase, or None when the repository has no
|
|
335
|
+
readable origin remote or the URL carries no owner/repo tail.
|
|
599
336
|
"""
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
token_index = 0
|
|
610
|
-
option_with_value_step = GIT_OPTION_WITH_VALUE_STEP
|
|
611
|
-
while token_index < len(all_following_tokens):
|
|
612
|
-
each_token = _strip_token_edge_quotes(all_following_tokens[token_index])
|
|
613
|
-
if each_token == GIT_WORKING_DIRECTORY_OPTION:
|
|
614
|
-
value_index = token_index + 1
|
|
615
|
-
if value_index >= len(all_following_tokens):
|
|
616
|
-
return None
|
|
617
|
-
return _strip_token_edge_quotes(all_following_tokens[value_index])
|
|
618
|
-
option_name = each_token
|
|
619
|
-
has_attached_value = False
|
|
620
|
-
if each_token.startswith(DOUBLE_DASH_OPTION_PREFIX) and "=" in each_token:
|
|
621
|
-
option_name, _, _attached_value = each_token.partition("=")
|
|
622
|
-
has_attached_value = True
|
|
623
|
-
if option_name in ALL_VALUE_TAKING_GIT_OPTIONS:
|
|
624
|
-
token_index += 1 if has_attached_value else option_with_value_step
|
|
625
|
-
continue
|
|
626
|
-
if each_token.startswith(SINGLE_DASH_OPTION_PREFIX):
|
|
627
|
-
token_index += 1
|
|
628
|
-
continue
|
|
629
|
-
return None
|
|
630
|
-
return None
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
def _segment_git_commit_working_directory(
|
|
634
|
-
all_segment_tokens: list[str],
|
|
635
|
-
) -> str | None:
|
|
636
|
-
command_index = _skip_leading_noop_tokens(all_segment_tokens)
|
|
637
|
-
if command_index >= len(all_segment_tokens):
|
|
337
|
+
try:
|
|
338
|
+
completed_process = subprocess.run(
|
|
339
|
+
list(ALL_GIT_ORIGIN_URL_COMMAND),
|
|
340
|
+
capture_output=True,
|
|
341
|
+
text=True,
|
|
342
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
343
|
+
cwd=str(repository_root),
|
|
344
|
+
)
|
|
345
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
638
346
|
return None
|
|
639
|
-
|
|
640
|
-
if not _token_is_git_binary(command_token):
|
|
347
|
+
if completed_process.returncode != 0:
|
|
641
348
|
return None
|
|
642
|
-
|
|
643
|
-
return _following_tokens_working_directory(all_following_tokens)
|
|
644
|
-
|
|
349
|
+
return _owner_repo_slug_from_origin_url(completed_process.stdout.strip())
|
|
645
350
|
|
|
646
|
-
def extract_git_commit_working_directory(shell_command: str) -> str | None:
|
|
647
|
-
"""Return the directory a git-commit command runs in, or None for the CWD.
|
|
648
351
|
|
|
649
|
-
|
|
650
|
-
|
|
352
|
+
def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
|
|
353
|
+
"""Report whether commits in *repository_root* skip the staged PII scan.
|
|
651
354
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
355
|
+
Exempt repositories are named by owner/repo slug through
|
|
356
|
+
``CLAUDE_PII_EXEMPT_REPOS`` or the ``pii_exempt_repositories`` list in the
|
|
357
|
+
git-ignored ``~/.claude/local-identity.json``. A repository without a readable
|
|
358
|
+
origin remote is never exempt (fail-closed to scanning).
|
|
656
359
|
|
|
657
360
|
Args:
|
|
658
|
-
|
|
361
|
+
repository_root: Repository whose index is about to be committed.
|
|
659
362
|
|
|
660
363
|
Returns:
|
|
661
|
-
|
|
364
|
+
True when the repository's origin slug is in the exempt set.
|
|
662
365
|
"""
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
if segment_directory is not None:
|
|
668
|
-
return segment_directory
|
|
669
|
-
return extract_git_working_directory(shell_command)
|
|
366
|
+
origin_slug = _repository_origin_slug(repository_root)
|
|
367
|
+
if origin_slug is None:
|
|
368
|
+
return False
|
|
369
|
+
return origin_slug in pii_exempt_repository_slugs()
|
|
670
370
|
|
|
671
371
|
|
|
672
372
|
def evaluate_bash_command(
|
|
@@ -698,6 +398,8 @@ def evaluate_bash_command(
|
|
|
698
398
|
repository_root = resolve_repository_root(resolved_directory)
|
|
699
399
|
if repository_root is None:
|
|
700
400
|
return REPOSITORY_ROOT_UNRESOLVED_REASON
|
|
401
|
+
if _is_repository_exempt_from_pii_scan(repository_root):
|
|
402
|
+
return None
|
|
701
403
|
return evaluate_staged_commit(repository_root)
|
|
702
404
|
|
|
703
405
|
|