claude-dev-env 2.30.1 → 2.31.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/.agents/agents/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +57 -31
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -19,6 +19,11 @@ try:
|
|
|
19
19
|
if each_bootstrap_directory not in sys.path:
|
|
20
20
|
sys.path.insert(0, each_bootstrap_directory)
|
|
21
21
|
from code_rules_shared import is_ephemeral_path
|
|
22
|
+
from codex_apply_patch import (
|
|
23
|
+
CodexPatchError,
|
|
24
|
+
parse_codex_apply_patch,
|
|
25
|
+
payload_patch_command,
|
|
26
|
+
)
|
|
22
27
|
from pii_prevention_blocker_parts.repository_exemption import (
|
|
23
28
|
repository_allowlisted_values,
|
|
24
29
|
)
|
|
@@ -48,6 +53,62 @@ except ImportError as import_error:
|
|
|
48
53
|
) from import_error
|
|
49
54
|
|
|
50
55
|
|
|
56
|
+
def _apply_patch_post_contents(
|
|
57
|
+
command: str, working_directory: str | None
|
|
58
|
+
) -> list[tuple[str, str]]:
|
|
59
|
+
"""Return (file_path, post_content) pairs for a Codex apply_patch command."""
|
|
60
|
+
if not command:
|
|
61
|
+
return []
|
|
62
|
+
try:
|
|
63
|
+
all_patch_files = parse_codex_apply_patch(command, working_directory)
|
|
64
|
+
except CodexPatchError:
|
|
65
|
+
return []
|
|
66
|
+
return [
|
|
67
|
+
(each_patch_file.file_path, each_patch_file.post_content)
|
|
68
|
+
for each_patch_file in all_patch_files
|
|
69
|
+
if each_patch_file.post_content
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _apply_patch_target_deny_reason(
|
|
74
|
+
each_file_path: str,
|
|
75
|
+
each_post_content: str,
|
|
76
|
+
hook_payload: dict | None,
|
|
77
|
+
) -> str | None:
|
|
78
|
+
"""Return the deny reason for one apply_patch target, or None when it is clean or exempt."""
|
|
79
|
+
if is_path_exempt_from_pii_scan(each_file_path):
|
|
80
|
+
return None
|
|
81
|
+
if _target_is_ephemeral_outside_repository(each_file_path, hook_payload):
|
|
82
|
+
return None
|
|
83
|
+
return _write_deny_reason_for_texts([each_post_content], each_file_path, frozenset())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def evaluate_apply_patch_payload(
|
|
87
|
+
all_tool_input: dict[str, object],
|
|
88
|
+
hook_payload: dict | None = None,
|
|
89
|
+
) -> str | None:
|
|
90
|
+
"""Return a deny reason when a Codex apply_patch payload's post content carries PII.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
all_tool_input: The apply_patch payload's ``command`` and other input fields.
|
|
94
|
+
hook_payload: The PreToolUse payload carrying ``cwd`` and the session id.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Deny reason text for the first target carrying non-allowlisted PII, or
|
|
98
|
+
None when every target is clean or out of scope.
|
|
99
|
+
"""
|
|
100
|
+
command, working_directory = payload_patch_command(hook_payload, all_tool_input)
|
|
101
|
+
for each_file_path, each_post_content in _apply_patch_post_contents(
|
|
102
|
+
command, working_directory
|
|
103
|
+
):
|
|
104
|
+
deny_reason = _apply_patch_target_deny_reason(
|
|
105
|
+
each_file_path, each_post_content, hook_payload
|
|
106
|
+
)
|
|
107
|
+
if deny_reason is not None:
|
|
108
|
+
return deny_reason
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
51
112
|
def _target_is_ephemeral_outside_repository(
|
|
52
113
|
file_path: str, hook_payload: dict | None
|
|
53
114
|
) -> bool:
|
|
@@ -40,6 +40,7 @@ try:
|
|
|
40
40
|
)
|
|
41
41
|
from pii_payload_scan import (
|
|
42
42
|
build_deny_reason,
|
|
43
|
+
evaluate_apply_patch_payload,
|
|
43
44
|
evaluate_post_body_texts,
|
|
44
45
|
evaluate_write_edit_payload,
|
|
45
46
|
)
|
|
@@ -66,6 +67,7 @@ try:
|
|
|
66
67
|
ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
|
|
67
68
|
ALL_STAGED_FILES_COMMAND,
|
|
68
69
|
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
70
|
+
APPLY_PATCH_TOOL_NAME,
|
|
69
71
|
BODY_FILE_ENCODING,
|
|
70
72
|
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
71
73
|
HOOK_SCRIPT_BASENAME,
|
|
@@ -92,6 +94,7 @@ except ImportError as import_error:
|
|
|
92
94
|
|
|
93
95
|
__all__ = [
|
|
94
96
|
"evaluate",
|
|
97
|
+
"evaluate_apply_patch_payload",
|
|
95
98
|
"evaluate_bash_command",
|
|
96
99
|
"evaluate_post_body_texts",
|
|
97
100
|
"evaluate_staged_commit",
|
|
@@ -332,6 +335,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
332
335
|
return evaluate_write_edit_payload(
|
|
333
336
|
tool_name, all_tool_input, hook_payload=payload_by_key
|
|
334
337
|
)
|
|
338
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
339
|
+
return evaluate_apply_patch_payload(all_tool_input, hook_payload=payload_by_key)
|
|
335
340
|
if tool_name in ALL_SHELL_TOOL_NAMES:
|
|
336
341
|
return _evaluate_shell_tool(all_tool_input, payload_by_key)
|
|
337
342
|
if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block gh pr create when no pr-description-writer spawn is recorded.
|
|
3
|
+
|
|
4
|
+
Picture a reviewer opening a fresh pull request and meeting a body that repeats
|
|
5
|
+
the branch name. The ``pr-description-writer`` agent exists to write that body
|
|
6
|
+
from the diff, and the spawn tracker records each time this session runs it.
|
|
7
|
+
|
|
8
|
+
This gate reads that record when the session runs ``gh pr create``::
|
|
9
|
+
|
|
10
|
+
gh pr create --body-file body.md flag: no spawn recorded
|
|
11
|
+
gh pr create --body-file body.md ok: a spawn recorded earlier
|
|
12
|
+
gh pr create ... # pr-description-skip ok: the author opted out
|
|
13
|
+
|
|
14
|
+
Detection strategy: tokenize the command, then check each position that opens a
|
|
15
|
+
command segment for a ``gh`` executable followed by ``pr`` and ``create``. The
|
|
16
|
+
bypass marker counts only as the command's final tokens, so the same text
|
|
17
|
+
inside a quoted title leaves the gate running.
|
|
18
|
+
|
|
19
|
+
The gate fails OPEN on anything it cannot read: a command that will not
|
|
20
|
+
tokenize, a payload that is not a shell tool, or a missing command. Only a
|
|
21
|
+
recognized ``gh pr create`` with no recorded spawn and no bypass marker denies.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import shlex
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
33
|
+
if _blocking_dir not in sys.path:
|
|
34
|
+
sys.path.insert(0, _blocking_dir)
|
|
35
|
+
|
|
36
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
37
|
+
if _hooks_dir not in sys.path:
|
|
38
|
+
sys.path.insert(0, _hooks_dir)
|
|
39
|
+
|
|
40
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
41
|
+
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
42
|
+
)
|
|
43
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
44
|
+
from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
|
|
45
|
+
ALL_GH_EXECUTABLE_BASENAMES,
|
|
46
|
+
COMMAND_FIELD_NAME,
|
|
47
|
+
CORRECTIVE_MESSAGE,
|
|
48
|
+
CREATE_SUBCOMMAND_TOKEN,
|
|
49
|
+
GH_PR_CREATE_MINIMUM_TOKEN_COUNT,
|
|
50
|
+
PR_SUBCOMMAND_TOKEN,
|
|
51
|
+
SPAWN_BYPASS_MARKER,
|
|
52
|
+
SPAWN_MARKER_FILE_PREFIX,
|
|
53
|
+
SPAWN_MARKER_FILE_SUFFIX,
|
|
54
|
+
TOOL_INPUT_FIELD_NAME,
|
|
55
|
+
TOOL_NAME_FIELD_NAME,
|
|
56
|
+
)
|
|
57
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
58
|
+
ALL_COMMAND_SEPARATOR_TOKENS,
|
|
59
|
+
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _all_command_tokens(shell_command: str) -> list[str] | None:
|
|
64
|
+
"""Return the command's tokens, or None when it will not tokenize.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
shell_command: The raw shell command string.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The token list, or None when shlex rejects the command.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
return shlex.split(shell_command, posix=True)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _carries_trailing_bypass_marker(all_tokens: list[str]) -> bool:
|
|
79
|
+
"""Report whether the bypass marker sits at the very end of the command.
|
|
80
|
+
|
|
81
|
+
The marker opts out only as a trailing shell comment the author adds on
|
|
82
|
+
purpose, so its parts are the command's final tokens. The same text inside
|
|
83
|
+
a quoted title tokenizes into the middle of the list and does not match.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
all_tokens: The tokens of the whole command.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True when the marker's tokens close the command.
|
|
90
|
+
"""
|
|
91
|
+
all_marker_tokens = SPAWN_BYPASS_MARKER.split()
|
|
92
|
+
marker_length = len(all_marker_tokens)
|
|
93
|
+
if marker_length > len(all_tokens):
|
|
94
|
+
return False
|
|
95
|
+
return all_tokens[-marker_length:] == all_marker_tokens
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _opens_pull_request_creation(all_leading_tokens: list[str]) -> bool:
|
|
99
|
+
"""Report whether these leading tokens open a gh pr create invocation.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
all_leading_tokens: The tokens that open one command segment.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True when they name a gh executable, then ``pr``, then ``create``.
|
|
106
|
+
"""
|
|
107
|
+
if len(all_leading_tokens) < GH_PR_CREATE_MINIMUM_TOKEN_COUNT:
|
|
108
|
+
return False
|
|
109
|
+
executable_basename = Path(all_leading_tokens[0]).name
|
|
110
|
+
if executable_basename not in ALL_GH_EXECUTABLE_BASENAMES:
|
|
111
|
+
return False
|
|
112
|
+
return all_leading_tokens[1:] == [PR_SUBCOMMAND_TOKEN, CREATE_SUBCOMMAND_TOKEN]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _all_command_segments(all_tokens: list[str]) -> list[list[str]]:
|
|
116
|
+
"""Return the command's token segments in their original order.
|
|
117
|
+
|
|
118
|
+
A shell separator ends the current segment and starts the next one.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
all_tokens: The tokens of the whole command.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
One token list for each command segment.
|
|
125
|
+
"""
|
|
126
|
+
all_segments: list[list[str]] = [[]]
|
|
127
|
+
for each_token in all_tokens:
|
|
128
|
+
if each_token in ALL_COMMAND_SEPARATOR_TOKENS:
|
|
129
|
+
all_segments.append([])
|
|
130
|
+
continue
|
|
131
|
+
all_segments[-1].append(each_token)
|
|
132
|
+
return all_segments
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _all_pull_request_creation_segment_indexes(all_segments: list[list[str]]) -> list[int]:
|
|
136
|
+
"""Return indexes for segments that open a gh pr create command.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
all_segments: The shell command's token segments.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The indexes of segments that open a gh pr create command.
|
|
143
|
+
"""
|
|
144
|
+
return [
|
|
145
|
+
each_index
|
|
146
|
+
for each_index, each_segment in enumerate(all_segments)
|
|
147
|
+
if _opens_pull_request_creation(each_segment[:GH_PR_CREATE_MINIMUM_TOKEN_COUNT])
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _only_final_create_carries_bypass(
|
|
152
|
+
all_tokens: list[str], all_creation_segment_indexes: list[int]
|
|
153
|
+
) -> bool:
|
|
154
|
+
"""Report whether one final gh pr create carries the final bypass comment.
|
|
155
|
+
|
|
156
|
+
The marker belongs to the command segment that creates the pull request.
|
|
157
|
+
A marker after another command, or after a second create, cannot opt an
|
|
158
|
+
earlier create out.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
all_tokens: The tokens of the whole command.
|
|
162
|
+
all_creation_segment_indexes: Indexes of segments that create pull requests.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True when one final create segment carries the final bypass marker.
|
|
166
|
+
"""
|
|
167
|
+
if not _carries_trailing_bypass_marker(all_tokens):
|
|
168
|
+
return False
|
|
169
|
+
final_segment_index = len(_all_command_segments(all_tokens)) - 1
|
|
170
|
+
return all_creation_segment_indexes == [final_segment_index]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _spawn_is_recorded(session_id: str) -> bool:
|
|
174
|
+
"""Report whether this session recorded a pr-description-writer spawn.
|
|
175
|
+
|
|
176
|
+
A payload naming no session reads as no record, so the gate denies rather
|
|
177
|
+
than falling back to a shared marker a different session could have left.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
session_id: Raw ``session_id`` from the hook payload.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True when this session's marker file is present.
|
|
184
|
+
"""
|
|
185
|
+
sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
|
|
186
|
+
if not sanitized_session_id:
|
|
187
|
+
return False
|
|
188
|
+
file_name = f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
|
|
189
|
+
return (Path(tempfile.gettempdir()) / file_name).exists()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _shell_command_or_none(all_hook_payload: dict[str, object]) -> str | None:
|
|
193
|
+
"""Return the shell command this payload carries, or None.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
all_hook_payload: The parsed PreToolUse payload.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The command string, or None when the payload runs no shell command.
|
|
200
|
+
"""
|
|
201
|
+
if all_hook_payload.get(TOOL_NAME_FIELD_NAME, "") not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
|
|
202
|
+
return None
|
|
203
|
+
tool_input = all_hook_payload.get(TOOL_INPUT_FIELD_NAME, {})
|
|
204
|
+
if not isinstance(tool_input, dict):
|
|
205
|
+
return None
|
|
206
|
+
shell_command = tool_input.get(COMMAND_FIELD_NAME, "")
|
|
207
|
+
if not isinstance(shell_command, str) or not shell_command:
|
|
208
|
+
return None
|
|
209
|
+
return shell_command
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _emit_denial() -> None:
|
|
213
|
+
"""Write the PreToolUse deny payload to stdout and log the block."""
|
|
214
|
+
deny_payload = {
|
|
215
|
+
"hookSpecificOutput": {
|
|
216
|
+
"hookEventName": "PreToolUse",
|
|
217
|
+
"permissionDecision": "deny",
|
|
218
|
+
"permissionDecisionReason": CORRECTIVE_MESSAGE,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
log_hook_block(
|
|
222
|
+
calling_hook_name="pr_description_writer_gate.py",
|
|
223
|
+
hook_event="PreToolUse",
|
|
224
|
+
block_reason=CORRECTIVE_MESSAGE,
|
|
225
|
+
)
|
|
226
|
+
sys.stdout.write(json.dumps(deny_payload) + "\n")
|
|
227
|
+
sys.stdout.flush()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main() -> None:
|
|
231
|
+
"""Deny a gh pr create that no recorded agent spawn or bypass marker covers.
|
|
232
|
+
|
|
233
|
+
Reads the PreToolUse payload from stdin and exits zero on every branch. A
|
|
234
|
+
tool that runs no shell, a missing command, a command that will not
|
|
235
|
+
tokenize, a command opening no pull request, a trailing bypass marker, and
|
|
236
|
+
a recorded spawn each pass through untouched.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
hook_payload = json.load(sys.stdin)
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
sys.exit(0)
|
|
242
|
+
if not isinstance(hook_payload, dict):
|
|
243
|
+
sys.exit(0)
|
|
244
|
+
shell_command = _shell_command_or_none(hook_payload)
|
|
245
|
+
if shell_command is None:
|
|
246
|
+
sys.exit(0)
|
|
247
|
+
all_tokens = _all_command_tokens(shell_command)
|
|
248
|
+
if all_tokens is None:
|
|
249
|
+
sys.exit(0)
|
|
250
|
+
all_segments = _all_command_segments(all_tokens)
|
|
251
|
+
all_creation_segment_indexes = _all_pull_request_creation_segment_indexes(all_segments)
|
|
252
|
+
if not all_creation_segment_indexes:
|
|
253
|
+
sys.exit(0)
|
|
254
|
+
if _only_final_create_carries_bypass(all_tokens, all_creation_segment_indexes):
|
|
255
|
+
sys.exit(0)
|
|
256
|
+
if _spawn_is_recorded(str(hook_payload.get("session_id") or "")):
|
|
257
|
+
sys.exit(0)
|
|
258
|
+
_emit_denial()
|
|
259
|
+
sys.exit(0)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse dispatcher that hosts Write, Edit, and
|
|
2
|
+
"""PreToolUse dispatcher that hosts Write, Edit, MultiEdit, and apply_patch blocking hooks.
|
|
3
3
|
|
|
4
4
|
Reads the tool payload from stdin once, selects the hosted hooks applicable to
|
|
5
5
|
the payload's tool name, runs each hook in-process via runpy in the fixed order
|
|
@@ -34,9 +34,11 @@ import sys
|
|
|
34
34
|
|
|
35
35
|
import _path_setup # noqa: F401
|
|
36
36
|
|
|
37
|
+
from codex_apply_patch import payload_patch_target_paths
|
|
37
38
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
38
39
|
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
39
|
-
|
|
40
|
+
ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
41
|
+
APPLY_PATCH_TOOL_NAME,
|
|
40
42
|
)
|
|
41
43
|
from hooks_constants.sensitive_file_protector_constants import (
|
|
42
44
|
ALL_SENSITIVE_PATTERNS,
|
|
@@ -108,7 +110,7 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
|
|
|
108
110
|
"""Record the block in the hook-blocks log and emit the deny decision.
|
|
109
111
|
|
|
110
112
|
Args:
|
|
111
|
-
file_path: The path the Write, Edit, or
|
|
113
|
+
file_path: The path the Write, Edit, MultiEdit, or apply_patch targets.
|
|
112
114
|
matched_pattern: The sensitive pattern the basename matched.
|
|
113
115
|
"""
|
|
114
116
|
deny_reason = DENY_REASON_TEMPLATE.format(
|
|
@@ -124,6 +126,15 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
|
|
|
124
126
|
sys.stdout.write(json.dumps(build_deny_response(deny_reason)))
|
|
125
127
|
|
|
126
128
|
|
|
129
|
+
def _deny_apply_patch_sensitive_target(hook_input: dict, tool_input: dict) -> None:
|
|
130
|
+
"""Deny the first apply_patch target path whose basename names a sensitive file."""
|
|
131
|
+
for each_target_path in payload_patch_target_paths(hook_input, tool_input):
|
|
132
|
+
matched_pattern = is_sensitive_file(each_target_path)
|
|
133
|
+
if matched_pattern is not None:
|
|
134
|
+
deny_write(each_target_path, matched_pattern)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
|
|
127
138
|
def main() -> None:
|
|
128
139
|
"""Deny the write when the stdin payload targets a sensitive filename."""
|
|
129
140
|
try:
|
|
@@ -134,7 +145,11 @@ def main() -> None:
|
|
|
134
145
|
tool_name = hook_input.get("tool_name", "")
|
|
135
146
|
tool_input = hook_input.get("tool_input", {})
|
|
136
147
|
|
|
137
|
-
if tool_name not in
|
|
148
|
+
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES:
|
|
149
|
+
sys.exit(0)
|
|
150
|
+
|
|
151
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
152
|
+
_deny_apply_patch_sensitive_target(hook_input, tool_input)
|
|
138
153
|
sys.exit(0)
|
|
139
154
|
|
|
140
155
|
file_path = tool_input.get("file_path", "")
|
|
@@ -54,53 +54,31 @@ import json
|
|
|
54
54
|
import sys
|
|
55
55
|
from pathlib import Path
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
sys.path.insert(0, _hooks_dir)
|
|
83
|
-
from hooks_constants.shell_substitution_blocker_constants import (
|
|
84
|
-
BASH_TOOL_NAME,
|
|
85
|
-
CLOSE_PAREN_CHARACTER,
|
|
86
|
-
COMMAND_KEY,
|
|
87
|
-
CORRECTIVE_MESSAGE,
|
|
88
|
-
DENY_DECISION,
|
|
89
|
-
DOLLAR_PAREN_PATTERN,
|
|
90
|
-
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
91
|
-
HOOK_EVENT_NAME,
|
|
92
|
-
HOOK_EVENT_NAME_KEY,
|
|
93
|
-
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
94
|
-
OPEN_PAREN_CHARACTER,
|
|
95
|
-
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
96
|
-
PERMISSION_DECISION_KEY,
|
|
97
|
-
PERMISSION_DECISION_REASON_KEY,
|
|
98
|
-
PROCESS_SUBSTITUTION_PATTERN,
|
|
99
|
-
SINGLE_QUOTED_RUN_PATTERN,
|
|
100
|
-
STRIPPED_RUN_REPLACEMENT,
|
|
101
|
-
TOOL_INPUT_KEY,
|
|
102
|
-
TOOL_NAME_KEY,
|
|
103
|
-
)
|
|
57
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
58
|
+
if _hooks_dir not in sys.path:
|
|
59
|
+
sys.path.insert(0, _hooks_dir)
|
|
60
|
+
|
|
61
|
+
from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
62
|
+
BASH_TOOL_NAME,
|
|
63
|
+
CLOSE_PAREN_CHARACTER,
|
|
64
|
+
COMMAND_KEY,
|
|
65
|
+
CORRECTIVE_MESSAGE,
|
|
66
|
+
DENY_DECISION,
|
|
67
|
+
DOLLAR_PAREN_PATTERN,
|
|
68
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
69
|
+
HOOK_EVENT_NAME,
|
|
70
|
+
HOOK_EVENT_NAME_KEY,
|
|
71
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
72
|
+
OPEN_PAREN_CHARACTER,
|
|
73
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
74
|
+
PERMISSION_DECISION_KEY,
|
|
75
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
76
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
77
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
78
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
79
|
+
TOOL_INPUT_KEY,
|
|
80
|
+
TOOL_NAME_KEY,
|
|
81
|
+
)
|
|
104
82
|
|
|
105
83
|
|
|
106
84
|
def _strip_single_quoted_runs(command: str) -> str:
|
|
@@ -11,28 +11,95 @@ directly above it, denying the edit when that line is a standalone ``#``
|
|
|
11
11
|
comment naming an identifier ``old_string`` carries and ``new_string`` drops.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
+
import functools
|
|
14
15
|
import json
|
|
15
16
|
import re
|
|
16
17
|
import sys
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import TextIO
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
sys.path
|
|
21
|
+
try:
|
|
22
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if _hooks_dir not in sys.path:
|
|
24
|
+
sys.path.insert(0, _hooks_dir)
|
|
23
25
|
|
|
24
|
-
from hooks_constants.hook_block_logger import log_hook_block
|
|
25
|
-
from hooks_constants.
|
|
26
|
-
from hooks_constants.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
27
|
+
from hooks_constants.multi_edit_reconstruction import edits_for_tool
|
|
28
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
29
|
+
EDIT_TOOL_NAME,
|
|
30
|
+
MULTI_EDIT_TOOL_NAME,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
33
|
+
from hooks_constants.stale_comment_reference_blocker_constants import (
|
|
34
|
+
ALL_COMMENT_STOPWORDS,
|
|
35
|
+
COMMENT_IDENTIFIER_PATTERN,
|
|
36
|
+
COMMENT_LINE_PREFIX,
|
|
37
|
+
PYTHON_FILE_SUFFIX,
|
|
38
|
+
STALE_COMMENT_ADDITIONAL_CONTEXT,
|
|
39
|
+
STALE_COMMENT_DENY_TEMPLATE,
|
|
40
|
+
STALE_COMMENT_SYSTEM_MESSAGE,
|
|
41
|
+
)
|
|
42
|
+
except ImportError as import_error:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"stale_comment_reference_blocker: cannot import its sibling modules; "
|
|
45
|
+
"ensure the hooks directory is importable."
|
|
46
|
+
) from import_error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _edit_step_fields(each_edit: object) -> tuple[str, str, bool] | None:
|
|
50
|
+
"""Return (old_string, new_string, is_replace_all) for one usable MultiEdit step."""
|
|
51
|
+
if not isinstance(each_edit, dict):
|
|
52
|
+
return None
|
|
53
|
+
old_string = each_edit.get("old_string", "")
|
|
54
|
+
new_string = each_edit.get("new_string", "")
|
|
55
|
+
if not isinstance(old_string, str) or not isinstance(new_string, str) or not old_string:
|
|
56
|
+
return None
|
|
57
|
+
return old_string, new_string, each_edit.get("replace_all") is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _evaluate_multi_edit(file_path: str, all_tool_input: dict[str, object]) -> str | None:
|
|
61
|
+
"""Judge each MultiEdit step against the content the prior steps already left.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
file_path: The destination path the MultiEdit targets.
|
|
65
|
+
all_tool_input: The MultiEdit payload's input mapping.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The deny-reason text for the first step whose kept comment orphans an
|
|
69
|
+
identifier, or None when every step stays consistent.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
current_content = Path(file_path).read_text(encoding="utf-8")
|
|
73
|
+
except (OSError, UnicodeDecodeError):
|
|
74
|
+
return None
|
|
75
|
+
for each_edit in edits_for_tool(MULTI_EDIT_TOOL_NAME, all_tool_input):
|
|
76
|
+
edit_fields = _edit_step_fields(each_edit)
|
|
77
|
+
if edit_fields is None:
|
|
78
|
+
continue
|
|
79
|
+
old_string, new_string, is_replace_all = edit_fields
|
|
80
|
+
if old_string not in current_content:
|
|
81
|
+
continue
|
|
82
|
+
deny_reason = _find_stale_comment_reference(
|
|
83
|
+
current_content, old_string, new_string, is_replace_all, file_path
|
|
84
|
+
)
|
|
85
|
+
if deny_reason is not None:
|
|
86
|
+
return deny_reason
|
|
87
|
+
current_content = current_content.replace(old_string, new_string, -1 if is_replace_all else 1)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@functools.lru_cache
|
|
92
|
+
def _bounded_identifier_pattern(identifier: str) -> re.Pattern[str]:
|
|
93
|
+
"""Return the compiled whole-word pattern for one comment-named identifier.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
identifier: The identifier the comment names.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The compiled pattern matching identifier with no adjoining word
|
|
100
|
+
character on either side.
|
|
101
|
+
"""
|
|
102
|
+
return re.compile("(?<![A-Za-z0-9_])" + re.escape(identifier) + "(?![A-Za-z0-9_])")
|
|
36
103
|
|
|
37
104
|
|
|
38
105
|
def _first_orphaned_identifier(
|
|
@@ -57,9 +124,7 @@ def _first_orphaned_identifier(
|
|
|
57
124
|
for each_identifier in COMMENT_IDENTIFIER_PATTERN.findall(words_in_comment):
|
|
58
125
|
if each_identifier.lower() in ALL_COMMENT_STOPWORDS:
|
|
59
126
|
continue
|
|
60
|
-
bounded_pattern =
|
|
61
|
-
"(?<![A-Za-z0-9_])" + re.escape(each_identifier) + "(?![A-Za-z0-9_])"
|
|
62
|
-
)
|
|
127
|
+
bounded_pattern = _bounded_identifier_pattern(each_identifier)
|
|
63
128
|
if bounded_pattern.search(original_block_text) and not bounded_pattern.search(
|
|
64
129
|
revised_block_text
|
|
65
130
|
):
|
|
@@ -159,13 +224,15 @@ def _find_stale_comment_reference(
|
|
|
159
224
|
|
|
160
225
|
|
|
161
226
|
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
162
|
-
"""Decide whether an Edit payload orphans a comment above a changed line.
|
|
227
|
+
"""Decide whether an Edit or MultiEdit payload orphans a comment above a changed line.
|
|
163
228
|
|
|
164
229
|
Reads the target file from disk and checks the line directly above each
|
|
165
|
-
occurrence
|
|
166
|
-
identifier the
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
occurrence a step rewrites for a kept standalone comment whose named
|
|
231
|
+
identifier the step carries and drops. A MultiEdit's steps are judged in
|
|
232
|
+
order against the content the prior steps already left, so a step that
|
|
233
|
+
only reads as stale once an earlier step in the same payload has run is
|
|
234
|
+
still caught. Non-Edit/MultiEdit tools, non-Python targets, unreadable
|
|
235
|
+
files, and an old_string absent from the file all pass.
|
|
169
236
|
|
|
170
237
|
Args:
|
|
171
238
|
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
@@ -175,7 +242,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
175
242
|
"""
|
|
176
243
|
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
177
244
|
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
178
|
-
if tool_name
|
|
245
|
+
if tool_name not in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
|
|
179
246
|
return None
|
|
180
247
|
|
|
181
248
|
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
@@ -185,6 +252,9 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
185
252
|
if not isinstance(file_path, str) or not file_path.endswith(PYTHON_FILE_SUFFIX):
|
|
186
253
|
return None
|
|
187
254
|
|
|
255
|
+
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
256
|
+
return _evaluate_multi_edit(file_path, tool_input)
|
|
257
|
+
|
|
188
258
|
raw_old_string = tool_input.get("old_string", "")
|
|
189
259
|
raw_new_string = tool_input.get("new_string", "")
|
|
190
260
|
old_string = raw_old_string if isinstance(raw_old_string, str) else ""
|