claude-dev-env 1.92.0 → 1.93.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/CLAUDE.md +5 -39
- package/_shared/CLAUDE.md +2 -1
- package/_shared/advisor/CLAUDE.md +21 -0
- package/_shared/advisor/advisor-protocol.md +142 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
- package/_shared/advisor/scripts/pyproject.toml +3 -0
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
- package/_shared/advisor/scripts/tier_model_ids.py +164 -0
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/precatch-rubric.md +65 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
- package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
- package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
- package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
- package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
- package/agents/CLAUDE.md +3 -2
- package/agents/clasp-deployment-orchestrator.md +2 -2
- package/agents/code-advisor.md +7 -5
- package/agents/code-verifier.md +6 -2
- package/agents/session-advisor.md +27 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
- package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
- package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
- package/bin/CLAUDE.md +1 -0
- package/bin/expand_home_directory_tokens.mjs +75 -0
- package/bin/install.mjs +21 -7
- package/bin/install.test.mjs +220 -10
- package/hooks/CLAUDE.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -1
- package/hooks/blocking/_path_setup.py +13 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
- package/hooks/blocking/code_rules_enforcer.py +6 -0
- package/hooks/blocking/code_rules_imports_logging.py +34 -1
- package/hooks/blocking/code_rules_paths_syspath.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +272 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
- package/hooks/blocking/pii_prevention_blocker.py +781 -0
- package/hooks/blocking/pii_scanner.py +340 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/stop_dispatcher.py +84 -0
- package/hooks/blocking/test__path_setup.py +39 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
- package/hooks/blocking/test_destructive_command_blocker.py +29 -9
- package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
- package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
- package/hooks/blocking/test_pii_scanner.py +165 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
- package/hooks/blocking/test_stop_dispatcher.py +187 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
- package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
- package/hooks/diagnostic/migrations/README.md +25 -26
- package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
- package/hooks/hooks.json +10 -120
- package/hooks/hooks_constants/CLAUDE.md +12 -3
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
- package/hooks/hooks_constants/local_identity.py +182 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
- package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
- package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
- package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
- package/hooks/hooks_constants/test_layout_constants.py +28 -0
- package/hooks/hooks_constants/test_local_identity.py +88 -0
- package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
- package/hooks/validators/README.md +5 -6
- package/hooks/validators/__init__.py +2 -2
- package/hooks/validators/python_style_checks.py +298 -243
- package/hooks/validators/run_all_validators.py +6 -0
- package/hooks/validators/test_python_style_checks.py +278 -163
- package/package.json +2 -2
- package/rules/CLAUDE.md +7 -1
- package/rules/bdd.md +1 -1
- package/rules/nas-ssh-invocation.md +6 -4
- package/rules/no-justification-noise.md +61 -0
- package/rules/testing.md +0 -2
- package/scripts/test_setup_project_paths.py +1 -1
- package/skills/CLAUDE.md +6 -3
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +113 -35
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +44 -22
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/reference/stop-conditions.md +23 -13
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
- package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +329 -68
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
- package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
- package/skills/bdd-protocol/SKILL.md +4 -5
- package/skills/bdd-protocol/references/anti-patterns.md +1 -1
- package/skills/bugteam/SKILL.md +8 -0
- package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
- package/skills/bugteam/reference/team-setup.md +7 -5
- package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
- package/skills/copilot-finding-triage/SKILL.md +124 -0
- package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
- package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
- package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
- package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
- package/skills/copilot-finding-triage/templates/notification.md +34 -0
- package/skills/copilot-review/SKILL.md +8 -6
- package/skills/findbugs/SKILL.md +4 -0
- package/skills/fixbugs/SKILL.md +8 -7
- package/skills/gotcha/CLAUDE.md +2 -2
- package/skills/gotcha/SKILL.md +4 -4
- package/skills/log-audit/SKILL.md +4 -6
- package/skills/monitor-open-prs/CLAUDE.md +1 -1
- package/skills/monitor-open-prs/SKILL.md +6 -1
- package/skills/orchestrator/SKILL.md +184 -0
- package/skills/orchestrator-refresh/SKILL.md +37 -0
- package/skills/post-audit-findings/SKILL.md +5 -9
- package/skills/pr-consistency-audit/SKILL.md +5 -1
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +86 -47
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +20 -17
- package/skills/pr-converge/reference/examples.md +63 -47
- package/skills/pr-converge/reference/fix-protocol.md +15 -15
- package/skills/pr-converge/reference/ground-rules.md +11 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
- package/skills/pr-converge/reference/per-tick.md +129 -107
- package/skills/pr-converge/reference/state-schema.md +15 -10
- package/skills/pr-converge/scripts/check_convergence.py +5 -4
- package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
- package/skills/pr-fix-protocol/SKILL.md +3 -8
- package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
- package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
- package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
- package/skills/pr-loop-lifecycle/SKILL.md +5 -9
- package/skills/pr-scope-resolve/SKILL.md +3 -6
- package/skills/privacy-hygiene/SKILL.md +114 -0
- package/skills/qbug/SKILL.md +8 -8
- package/skills/rebase/SKILL.md +5 -1
- package/skills/refine/SKILL.md +4 -5
- package/skills/reviewer-gates/SKILL.md +7 -11
- package/skills/session-log/SKILL.md +4 -1
- package/skills/skill-builder/SKILL.md +3 -6
- package/skills/structure-prompt/SKILL.md +4 -5
- package/skills/team-advisor/SKILL.md +32 -164
- package/skills/test_markdown_link_integrity.py +10 -6
- package/skills/update/SKILL.md +5 -1
- package/skills/usage-pause/SKILL.md +14 -5
- package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
- package/skills/verified-build/SKILL.md +4 -9
- package/skills/team-advisor-refresh/SKILL.md +0 -25
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block writes, durable posts, and commits that carry PII.
|
|
3
|
+
|
|
4
|
+
Surfaces guarded:
|
|
5
|
+
|
|
6
|
+
- Write / Edit / MultiEdit — new content about to land on disk
|
|
7
|
+
- Bash / PowerShell ``gh`` post subcommands and GitHub MCP post tools — durable
|
|
8
|
+
bodies
|
|
9
|
+
- Bash / PowerShell ``git commit`` (including ``git.exe`` and flag forms) —
|
|
10
|
+
staged file contents about to become history. Commit message bodies are out
|
|
11
|
+
of scope; only staged blob text is scanned.
|
|
12
|
+
|
|
13
|
+
Detection reuses the pure scanners in ``pii_scanner`` and the post-body
|
|
14
|
+
extraction helpers already used by ``volatile_path_in_post_blocker``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
27
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
28
|
+
if _blocking_directory not in sys.path:
|
|
29
|
+
sys.path.insert(0, _blocking_directory)
|
|
30
|
+
if _hooks_directory not in sys.path:
|
|
31
|
+
sys.path.insert(0, _hooks_directory)
|
|
32
|
+
|
|
33
|
+
from block_main_commit import ( # noqa: E402
|
|
34
|
+
extract_git_working_directory,
|
|
35
|
+
resolve_directory,
|
|
36
|
+
)
|
|
37
|
+
from pii_scanner import PiiFinding, is_path_exempt_from_pii_scan, scan_text_for_pii # noqa: E402
|
|
38
|
+
from precommit_code_rules_gate import ( # noqa: E402
|
|
39
|
+
resolve_repository_root,
|
|
40
|
+
)
|
|
41
|
+
from volatile_path_in_post_blocker import ( # noqa: E402
|
|
42
|
+
extract_gh_post_body_texts_for_privacy_gate,
|
|
43
|
+
extract_mcp_body_texts,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
47
|
+
from hooks_constants.multi_edit_reconstruction import edits_for_tool # noqa: E402
|
|
48
|
+
from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
49
|
+
ALL_BASH_FAMILY_INTERPRETER_BASENAMES,
|
|
50
|
+
ALL_COMMAND_BOUNDARY_NEWLINE_CHARACTERS,
|
|
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_SHELL_TOOL_NAMES,
|
|
58
|
+
ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
|
|
59
|
+
ALL_STAGED_FILES_COMMAND,
|
|
60
|
+
ALL_VALUE_TAKING_GIT_OPTIONS,
|
|
61
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
62
|
+
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
|
+
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
70
|
+
GIT_COMMIT_SUBCOMMAND,
|
|
71
|
+
GIT_OPTION_WITH_VALUE_STEP,
|
|
72
|
+
GIT_WORKING_DIRECTORY_OPTION,
|
|
73
|
+
HOOK_SCRIPT_BASENAME,
|
|
74
|
+
INLINE_COMMAND_FLAG_CLUSTER_CHARACTER,
|
|
75
|
+
INLINE_COMMAND_TOKEN_JOINER,
|
|
76
|
+
LINE_CONTINUATION_PATTERN,
|
|
77
|
+
MAXIMUM_STAGED_FILE_BYTES,
|
|
78
|
+
MCP_GITHUB_TOOL_PREFIX,
|
|
79
|
+
MESSAGE_LINE_SEPARATOR,
|
|
80
|
+
MULTI_EDIT_TOOL_NAME,
|
|
81
|
+
NULL_BYTE_MARKER,
|
|
82
|
+
POWERSHELL_INLINE_COMMAND_FLAG,
|
|
83
|
+
POWERSHELL_LINE_CONTINUATION_PATTERN,
|
|
84
|
+
REPOSITORY_ROOT_UNRESOLVED_REASON,
|
|
85
|
+
SHELL_INLINE_COMMAND_FLAG,
|
|
86
|
+
SINGLE_DASH_OPTION_PREFIX,
|
|
87
|
+
STAGED_BLOB_PREFIX,
|
|
88
|
+
STAGED_BLOB_REASON_DECODE_FAILED,
|
|
89
|
+
STAGED_BLOB_REASON_GIT_SHOW_FAILED,
|
|
90
|
+
STAGED_BLOB_REASON_NULL_BYTES,
|
|
91
|
+
STAGED_BLOB_REASON_OVERSIZED,
|
|
92
|
+
STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE,
|
|
93
|
+
STAGED_LIST_FAILURE_REASON,
|
|
94
|
+
SUBSHELL_GROUP_OPEN_TOKEN,
|
|
95
|
+
WRITE_TOOL_NAME,
|
|
96
|
+
)
|
|
97
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
98
|
+
read_hook_input_dictionary_from_stdin,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
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
|
+
def list_staged_file_paths(
|
|
198
|
+
repository_root: Path,
|
|
199
|
+
) -> tuple[list[str] | None, str | None]:
|
|
200
|
+
"""List staged non-deleted paths, or report a list failure.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
repository_root: Repository root used as the git working directory.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
``(paths, None)`` on success, or ``(None, deny_reason)`` when the
|
|
207
|
+
staged list cannot be read (fail-closed for commit gating).
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
completed_process = subprocess.run(
|
|
211
|
+
list(ALL_STAGED_FILES_COMMAND),
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
215
|
+
cwd=str(repository_root),
|
|
216
|
+
)
|
|
217
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
218
|
+
return None, STAGED_LIST_FAILURE_REASON
|
|
219
|
+
if completed_process.returncode != 0:
|
|
220
|
+
return None, STAGED_LIST_FAILURE_REASON
|
|
221
|
+
all_paths = [
|
|
222
|
+
each_line.strip()
|
|
223
|
+
for each_line in completed_process.stdout.splitlines()
|
|
224
|
+
if each_line.strip()
|
|
225
|
+
]
|
|
226
|
+
return all_paths, None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def read_staged_file_text(
|
|
230
|
+
repository_root: Path, relative_path: str
|
|
231
|
+
) -> tuple[str | None, str | None]:
|
|
232
|
+
"""Return staged blob text, or report why the blob is unscannable.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
repository_root: Repository root for the git show working directory.
|
|
236
|
+
relative_path: Repository-relative path of the staged file.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
``(text, None)`` when the blob is scannable UTF-8 text, or
|
|
240
|
+
``(None, deny_reason)`` when the blob cannot be scanned (fail-closed).
|
|
241
|
+
"""
|
|
242
|
+
staged_blob_reference = STAGED_BLOB_PREFIX + relative_path
|
|
243
|
+
try:
|
|
244
|
+
completed_process = subprocess.run(
|
|
245
|
+
list(ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX) + [staged_blob_reference],
|
|
246
|
+
capture_output=True,
|
|
247
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
248
|
+
cwd=str(repository_root),
|
|
249
|
+
)
|
|
250
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
251
|
+
return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
|
|
252
|
+
relative_path=relative_path,
|
|
253
|
+
reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
|
|
254
|
+
)
|
|
255
|
+
if completed_process.returncode != 0:
|
|
256
|
+
return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
|
|
257
|
+
relative_path=relative_path,
|
|
258
|
+
reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
|
|
259
|
+
)
|
|
260
|
+
raw_bytes = completed_process.stdout
|
|
261
|
+
if len(raw_bytes) > MAXIMUM_STAGED_FILE_BYTES:
|
|
262
|
+
return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
|
|
263
|
+
relative_path=relative_path,
|
|
264
|
+
reason=STAGED_BLOB_REASON_OVERSIZED,
|
|
265
|
+
)
|
|
266
|
+
if NULL_BYTE_MARKER in raw_bytes:
|
|
267
|
+
return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
|
|
268
|
+
relative_path=relative_path,
|
|
269
|
+
reason=STAGED_BLOB_REASON_NULL_BYTES,
|
|
270
|
+
)
|
|
271
|
+
try:
|
|
272
|
+
return raw_bytes.decode(BODY_FILE_ENCODING), None
|
|
273
|
+
except UnicodeDecodeError:
|
|
274
|
+
return None, STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
|
|
275
|
+
relative_path=relative_path,
|
|
276
|
+
reason=STAGED_BLOB_REASON_DECODE_FAILED,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def evaluate_staged_commit(
|
|
281
|
+
repository_root: Path,
|
|
282
|
+
) -> str | None:
|
|
283
|
+
"""Return a deny reason when staged content carries PII or is unscannable.
|
|
284
|
+
|
|
285
|
+
Fail-closed: git list/show failures and unscannable blobs deny the commit
|
|
286
|
+
rather than treating unread content as clean.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
repository_root: Repository whose index is about to be committed.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Deny reason text, or None when every scannable staged path is clean.
|
|
293
|
+
"""
|
|
294
|
+
all_relative_paths, list_failure_reason = list_staged_file_paths(repository_root)
|
|
295
|
+
if list_failure_reason is not None or all_relative_paths is None:
|
|
296
|
+
return list_failure_reason or STAGED_LIST_FAILURE_REASON
|
|
297
|
+
for each_relative_path in all_relative_paths:
|
|
298
|
+
if is_path_exempt_from_pii_scan(each_relative_path):
|
|
299
|
+
continue
|
|
300
|
+
staged_text, unscannable_reason = read_staged_file_text(
|
|
301
|
+
repository_root, each_relative_path
|
|
302
|
+
)
|
|
303
|
+
if unscannable_reason is not None:
|
|
304
|
+
return unscannable_reason
|
|
305
|
+
if staged_text is None:
|
|
306
|
+
return STAGED_BLOB_UNSCANNABLE_REASON_TEMPLATE.format(
|
|
307
|
+
relative_path=each_relative_path,
|
|
308
|
+
reason=STAGED_BLOB_REASON_GIT_SHOW_FAILED,
|
|
309
|
+
)
|
|
310
|
+
all_findings = scan_text_for_pii(staged_text)
|
|
311
|
+
if all_findings:
|
|
312
|
+
gate_surface = f"staged commit ({each_relative_path})"
|
|
313
|
+
return build_deny_reason(all_findings, gate_surface)
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _strip_token_edge_quotes(token_text: str) -> str:
|
|
318
|
+
return token_text.strip("\"'")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _token_basename_lower(token_text: str) -> str:
|
|
322
|
+
stripped_token = _strip_token_edge_quotes(token_text)
|
|
323
|
+
return re.split(r"[\\/]", stripped_token)[-1].lower()
|
|
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
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _token_is_skippable_prefix(token_text: str) -> bool:
|
|
343
|
+
if ENVIRONMENT_ASSIGNMENT_PATTERN.match(token_text):
|
|
344
|
+
return True
|
|
345
|
+
return _token_basename_lower(token_text) in ALL_LEADING_SKIPPABLE_COMMAND_TOKENS
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _following_tokens_invoke_commit(all_following_tokens: list[str]) -> bool:
|
|
349
|
+
token_index = 0
|
|
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
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _split_shell_command_segments(all_tokens: list[str]) -> list[list[str]]:
|
|
372
|
+
all_segments: list[list[str]] = [[]]
|
|
373
|
+
shell_separators = ALL_SHELL_COMMAND_SEPARATOR_TOKENS
|
|
374
|
+
for each_token in all_tokens:
|
|
375
|
+
if each_token in shell_separators:
|
|
376
|
+
all_segments.append([])
|
|
377
|
+
continue
|
|
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 = ""
|
|
387
|
+
try:
|
|
388
|
+
return list(lexer)
|
|
389
|
+
except ValueError:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _fallback_segments_by_physical_line(shell_command_piece: str) -> list[list[str]]:
|
|
394
|
+
all_segments: list[list[str]] = []
|
|
395
|
+
for each_physical_line in shell_command_piece.splitlines():
|
|
396
|
+
line_tokens = each_physical_line.split()
|
|
397
|
+
all_segments.extend(_split_shell_command_segments(line_tokens))
|
|
398
|
+
return all_segments
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _segments_for_piece(shell_command_piece: str) -> list[list[str]]:
|
|
402
|
+
piece_tokens = _tokenize_shell_command(shell_command_piece)
|
|
403
|
+
if piece_tokens is None:
|
|
404
|
+
return _fallback_segments_by_physical_line(shell_command_piece)
|
|
405
|
+
return _split_shell_command_segments(piece_tokens)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _split_on_unquoted_newlines(shell_command: str) -> list[str]:
|
|
409
|
+
all_pieces: list[str] = []
|
|
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
|
+
|
|
439
|
+
|
|
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
|
+
|
|
443
|
+
|
|
444
|
+
def _token_is_wrapper_option_flag(token_text: str) -> bool:
|
|
445
|
+
return token_text.startswith(SINGLE_DASH_OPTION_PREFIX)
|
|
446
|
+
|
|
447
|
+
|
|
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
|
+
|
|
453
|
+
|
|
454
|
+
def _flag_value_token_follows(all_segment_tokens: list[str], value_index: int) -> bool:
|
|
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
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _token_is_powershell_command_flag_prefix(lowered_token: str) -> bool:
|
|
503
|
+
if lowered_token == SINGLE_DASH_OPTION_PREFIX:
|
|
504
|
+
return False
|
|
505
|
+
return POWERSHELL_INLINE_COMMAND_FLAG.startswith(lowered_token)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _token_is_interpreter_inline_command_flag(
|
|
509
|
+
token_text: str, interpreter_allows_short_flag_cluster: bool
|
|
510
|
+
) -> bool:
|
|
511
|
+
if not token_text.startswith(SINGLE_DASH_OPTION_PREFIX):
|
|
512
|
+
return False
|
|
513
|
+
if token_text.startswith(DOUBLE_DASH_OPTION_PREFIX):
|
|
514
|
+
return False
|
|
515
|
+
lowered_token = token_text.lower()
|
|
516
|
+
if lowered_token == SHELL_INLINE_COMMAND_FLAG:
|
|
517
|
+
return True
|
|
518
|
+
if _token_is_powershell_command_flag_prefix(lowered_token):
|
|
519
|
+
return True
|
|
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
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _segment_invokes_git_commit(all_segment_tokens: list[str]) -> bool:
|
|
548
|
+
command_index = _skip_leading_noop_tokens(all_segment_tokens)
|
|
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.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
shell_command: Bash or PowerShell tool command string.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
True when a command segment invokes git with a commit subcommand.
|
|
599
|
+
"""
|
|
600
|
+
if not shell_command or not shell_command.strip():
|
|
601
|
+
return False
|
|
602
|
+
for each_segment in _all_command_segments(shell_command):
|
|
603
|
+
if _segment_invokes_git_commit(each_segment):
|
|
604
|
+
return True
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _following_tokens_working_directory(all_following_tokens: list[str]) -> str | None:
|
|
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):
|
|
638
|
+
return None
|
|
639
|
+
command_token = all_segment_tokens[command_index]
|
|
640
|
+
if not _token_is_git_binary(command_token):
|
|
641
|
+
return None
|
|
642
|
+
all_following_tokens = all_segment_tokens[command_index + 1 :]
|
|
643
|
+
return _following_tokens_working_directory(all_following_tokens)
|
|
644
|
+
|
|
645
|
+
|
|
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
|
+
|
|
649
|
+
Reads the ``-C`` value off the same commit-invoking segment the detector
|
|
650
|
+
matches, so every git-binary shape the detector recognizes resolves too::
|
|
651
|
+
|
|
652
|
+
git.exe -C /repoA commit -m x -> /repoA
|
|
653
|
+
git -C "C:/repo" commit -m x -> C:/repo
|
|
654
|
+
cd /repoB && git commit -m x -> /repoB (cd/pushd fallback)
|
|
655
|
+
git commit -m x -> None (runs in the CWD)
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
shell_command: Bash or PowerShell tool command string.
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
The working directory the commit targets, or None when it uses the CWD.
|
|
662
|
+
"""
|
|
663
|
+
for each_segment in _all_command_segments(shell_command):
|
|
664
|
+
if not _segment_invokes_git_commit(each_segment):
|
|
665
|
+
continue
|
|
666
|
+
segment_directory = _segment_git_commit_working_directory(each_segment)
|
|
667
|
+
if segment_directory is not None:
|
|
668
|
+
return segment_directory
|
|
669
|
+
return extract_git_working_directory(shell_command)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def evaluate_bash_command(
|
|
673
|
+
bash_command: str, working_directory: str | None
|
|
674
|
+
) -> str | None:
|
|
675
|
+
"""Return a deny reason for a shell gh post or git commit with PII.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
bash_command: The Bash or PowerShell tool command string.
|
|
679
|
+
working_directory: Directory git should run in, or None for process CWD.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Deny reason text, or None when the command is clean or out of scope.
|
|
683
|
+
"""
|
|
684
|
+
all_post_bodies, body_file_failure_reason = (
|
|
685
|
+
extract_gh_post_body_texts_for_privacy_gate(
|
|
686
|
+
bash_command, working_directory=working_directory
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
if body_file_failure_reason is not None:
|
|
690
|
+
return body_file_failure_reason
|
|
691
|
+
post_deny_reason = evaluate_post_body_texts(all_post_bodies)
|
|
692
|
+
if post_deny_reason is not None:
|
|
693
|
+
return post_deny_reason
|
|
694
|
+
if not is_git_commit_shell_command(bash_command):
|
|
695
|
+
return None
|
|
696
|
+
command_directory = extract_git_commit_working_directory(bash_command)
|
|
697
|
+
resolved_directory = resolve_directory(command_directory) or working_directory
|
|
698
|
+
repository_root = resolve_repository_root(resolved_directory)
|
|
699
|
+
if repository_root is None:
|
|
700
|
+
return REPOSITORY_ROOT_UNRESOLVED_REASON
|
|
701
|
+
return evaluate_staged_commit(repository_root)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
705
|
+
"""Decide whether a PreToolUse payload carries high-confidence PII.
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
Deny-reason text when blocked, or None when allowed.
|
|
712
|
+
"""
|
|
713
|
+
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
714
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
715
|
+
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
716
|
+
all_tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
717
|
+
|
|
718
|
+
if tool_name in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
719
|
+
return evaluate_write_edit_payload(tool_name, all_tool_input)
|
|
720
|
+
|
|
721
|
+
if tool_name in ALL_SHELL_TOOL_NAMES:
|
|
722
|
+
command_value = all_tool_input.get("command", "")
|
|
723
|
+
if not isinstance(command_value, str) or not command_value:
|
|
724
|
+
return None
|
|
725
|
+
working_directory_value = all_tool_input.get("working_directory")
|
|
726
|
+
working_directory = (
|
|
727
|
+
working_directory_value
|
|
728
|
+
if isinstance(working_directory_value, str)
|
|
729
|
+
else None
|
|
730
|
+
)
|
|
731
|
+
if working_directory is None:
|
|
732
|
+
cwd_value = payload_by_key.get("cwd")
|
|
733
|
+
working_directory = cwd_value if isinstance(cwd_value, str) else None
|
|
734
|
+
return evaluate_bash_command(
|
|
735
|
+
command_value, working_directory=working_directory
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
|
|
739
|
+
return evaluate_post_body_texts(extract_mcp_body_texts(all_tool_input))
|
|
740
|
+
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
745
|
+
"""Build the PreToolUse deny payload for *deny_reason*.
|
|
746
|
+
|
|
747
|
+
Args:
|
|
748
|
+
deny_reason: The permissionDecisionReason text.
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
Deny payload dictionary serialized to stdout by the hook.
|
|
752
|
+
"""
|
|
753
|
+
log_hook_block(
|
|
754
|
+
calling_hook_name=HOOK_SCRIPT_BASENAME,
|
|
755
|
+
hook_event="PreToolUse",
|
|
756
|
+
block_reason=deny_reason,
|
|
757
|
+
)
|
|
758
|
+
return {
|
|
759
|
+
"hookSpecificOutput": {
|
|
760
|
+
"hookEventName": "PreToolUse",
|
|
761
|
+
"permissionDecision": "deny",
|
|
762
|
+
"permissionDecisionReason": deny_reason,
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def main() -> None:
|
|
768
|
+
"""Read PreToolUse stdin and deny when high-confidence PII is present."""
|
|
769
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
770
|
+
if payload_dictionary is None:
|
|
771
|
+
sys.exit(0)
|
|
772
|
+
deny_reason = evaluate(payload_dictionary)
|
|
773
|
+
if deny_reason is None:
|
|
774
|
+
sys.exit(0)
|
|
775
|
+
print(json.dumps(build_deny_payload(deny_reason)))
|
|
776
|
+
sys.stdout.flush()
|
|
777
|
+
sys.exit(0)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
if __name__ == "__main__":
|
|
781
|
+
main()
|