claude-dev-env 7.4.0 → 7.5.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/bin/install.mjs +0 -1
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +4 -4
- package/bin/pstack.mjs +24 -4
- package/bin/pstack.test.mjs +20 -10
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +7 -13
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +1 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
- package/hooks/blocking/env_var_table_rows.py +130 -0
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/pii_scanner.py +19 -29
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
- package/hooks/blocking/test_env_var_table_rows.py +100 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/blocking/test_pii_scanner.py +34 -24
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +36 -1
- package/scripts/policy_lint/config/constants.py +74 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +6 -2
- package/scripts/pstack-adapters/common.md +1 -1
- package/scripts/repository_checks/config/constants.py +7 -17
- package/scripts/repository_checks/config/email_exemptions.py +10 -0
- package/scripts/repository_checks/email_exemptions.py +96 -0
- package/scripts/repository_checks/env_var_documentation.py +1 -9
- package/scripts/repository_checks/tracked_secrets.py +10 -4
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_email_exemptions.py +121 -0
- package/scripts/tests/test_env_var_documentation.py +26 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +107 -0
- package/scripts/tests/test_tracked_secrets.py +13 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- package/hooks/hooks_constants/text_stripping.py +0 -36
|
@@ -18,7 +18,8 @@ decision -- it is an observer, not a gate -- so this dispatcher runs the whole
|
|
|
18
18
|
roster unconditionally and never blocks. The one thing it forwards is context:
|
|
19
19
|
a hosted hook may print a ``hookSpecificOutput.additionalContext`` string (the
|
|
20
20
|
PR done reminder does), and the dispatcher joins every such string into one
|
|
21
|
-
PostToolUse payload
|
|
21
|
+
PostToolUse payload through the shared emitter every PostToolUse hook here
|
|
22
|
+
writes with. A hook that prints nothing adds nothing.
|
|
22
23
|
|
|
23
24
|
A single hosted hook crash fails open: ``run_hook_capturing_output`` isolates
|
|
24
25
|
it, so it contributes nothing and does not stop the remaining hosted hooks.
|
|
@@ -30,7 +31,6 @@ imports load once into this process rather than once per hook.
|
|
|
30
31
|
from __future__ import annotations
|
|
31
32
|
|
|
32
33
|
import json
|
|
33
|
-
import sys
|
|
34
34
|
|
|
35
35
|
import _path_setup # noqa: F401
|
|
36
36
|
|
|
@@ -39,8 +39,8 @@ from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
|
39
39
|
ADDITIONAL_CONTEXT_KEY,
|
|
40
40
|
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
41
41
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
42
|
-
POST_TOOL_USE_HOOK_EVENT_NAME,
|
|
43
42
|
)
|
|
43
|
+
from hooks_constants.post_tool_use_context import write_post_tool_use_context_to_stdout
|
|
44
44
|
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BashHostedHookEntry
|
|
45
45
|
from hooks_constants.hosted_hook_runner import (
|
|
46
46
|
run_hook_capturing_output,
|
|
@@ -98,14 +98,9 @@ def dispatch(payload_text: str, tool_name: str) -> None:
|
|
|
98
98
|
all_additional_context.append(additional_context)
|
|
99
99
|
if not all_additional_context:
|
|
100
100
|
return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
ADDITIONAL_CONTEXT_KEY: ADDITIONAL_CONTEXT_JOIN_SEPARATOR.join(all_additional_context),
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
sys.stdout.write(json.dumps(payload))
|
|
108
|
-
sys.stdout.flush()
|
|
101
|
+
write_post_tool_use_context_to_stdout(
|
|
102
|
+
ADDITIONAL_CONTEXT_JOIN_SEPARATOR.join(all_additional_context)
|
|
103
|
+
)
|
|
109
104
|
|
|
110
105
|
|
|
111
106
|
def main() -> None:
|
|
@@ -25,22 +25,23 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
25
25
|
if _hooks_dir not in sys.path:
|
|
26
26
|
sys.path.insert(0, _hooks_dir)
|
|
27
27
|
|
|
28
|
-
from
|
|
29
|
-
|
|
28
|
+
from env_var_table_rows import (
|
|
29
|
+
_code_file_reference_in_cell,
|
|
30
|
+
_env_var_name_in_cell,
|
|
31
|
+
_is_separator_row,
|
|
32
|
+
_row_cells,
|
|
33
|
+
iter_env_var_table_rows,
|
|
34
|
+
)
|
|
35
|
+
from hooks_constants.env_var_table_code_drift_constants import (
|
|
30
36
|
ALL_NOISE_DIRECTORY_NAMES,
|
|
31
|
-
BACKTICK_TOKEN_PATTERN,
|
|
32
|
-
CODE_FENCE_PATTERN,
|
|
33
37
|
DRIFT_ADDITIONAL_CONTEXT,
|
|
34
38
|
DRIFT_MESSAGE_TEMPLATE,
|
|
35
39
|
DRIFT_SYSTEM_MESSAGE,
|
|
36
|
-
ENV_VAR_NAME_PATTERN,
|
|
37
40
|
GIT_DIRECTORY_NAME,
|
|
38
41
|
MARKDOWN_FILE_EXTENSION,
|
|
39
42
|
MAX_DRIFT_ISSUES,
|
|
40
43
|
MAX_SUBTREE_FILES_SCANNED,
|
|
41
44
|
MINIMUM_ENV_VAR_ROW_CELL_COUNT,
|
|
42
|
-
SEPARATOR_CELL_PATTERN,
|
|
43
|
-
TABLE_ROW_PATTERN,
|
|
44
45
|
)
|
|
45
46
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
46
47
|
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
@@ -65,87 +66,6 @@ def is_markdown_file(file_path: str) -> bool:
|
|
|
65
66
|
return extension.lower() == MARKDOWN_FILE_EXTENSION
|
|
66
67
|
|
|
67
68
|
|
|
68
|
-
def _row_cells(table_line: str) -> list[str]:
|
|
69
|
-
"""Return the trimmed cells of one markdown table row.
|
|
70
|
-
|
|
71
|
-
Args:
|
|
72
|
-
table_line: A single line that begins with a pipe character.
|
|
73
|
-
|
|
74
|
-
Returns:
|
|
75
|
-
The text of each pipe-delimited cell, stripped, with the empty leading
|
|
76
|
-
and trailing segments a bounding pipe produces removed.
|
|
77
|
-
"""
|
|
78
|
-
stripped_line = table_line.strip()
|
|
79
|
-
inner = stripped_line.strip("|")
|
|
80
|
-
return [each_cell.strip() for each_cell in inner.split("|")]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _first_backtick_token(cell_text: str) -> str | None:
|
|
84
|
-
"""Return the first backtick-wrapped token in a cell, when it has one.
|
|
85
|
-
|
|
86
|
-
Args:
|
|
87
|
-
cell_text: The trimmed text of a table cell.
|
|
88
|
-
|
|
89
|
-
Returns:
|
|
90
|
-
The text inside the first pair of backticks, or None when the cell
|
|
91
|
-
carries no backtick-wrapped token.
|
|
92
|
-
"""
|
|
93
|
-
token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
|
|
94
|
-
if token_match is None:
|
|
95
|
-
return None
|
|
96
|
-
inner_text = token_match.group(1).strip()
|
|
97
|
-
return inner_text or None
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _env_var_name_in_cell(cell_text: str) -> str | None:
|
|
101
|
-
"""Return the environment-variable name a cell names, when it names one.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
cell_text: The trimmed text of a table cell.
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
The UPPER_SNAKE variable name inside the first backticks, or None when
|
|
108
|
-
the cell names no variable-shaped token.
|
|
109
|
-
"""
|
|
110
|
-
token = _first_backtick_token(cell_text)
|
|
111
|
-
if token is None:
|
|
112
|
-
return None
|
|
113
|
-
if ENV_VAR_NAME_PATTERN.match(token) is None:
|
|
114
|
-
return None
|
|
115
|
-
return token
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _code_file_reference_in_cell(cell_text: str) -> str | None:
|
|
119
|
-
"""Return the code-file path a cell names, when it names one.
|
|
120
|
-
|
|
121
|
-
Args:
|
|
122
|
-
cell_text: The trimmed text of a table cell.
|
|
123
|
-
|
|
124
|
-
Returns:
|
|
125
|
-
The relative code-file path inside the first backticks, or None when the
|
|
126
|
-
token carries no recognized code-file extension.
|
|
127
|
-
"""
|
|
128
|
-
token = _first_backtick_token(cell_text)
|
|
129
|
-
if token is None:
|
|
130
|
-
return None
|
|
131
|
-
_, extension = os.path.splitext(token)
|
|
132
|
-
if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
|
|
133
|
-
return None
|
|
134
|
-
return token
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _is_separator_row(all_cells: list[str]) -> bool:
|
|
138
|
-
"""Return whether every cell is a markdown table header-separator cell.
|
|
139
|
-
|
|
140
|
-
Args:
|
|
141
|
-
all_cells: The trimmed cells of one table row.
|
|
142
|
-
|
|
143
|
-
Returns:
|
|
144
|
-
True when each cell holds only dashes, colons, and whitespace.
|
|
145
|
-
"""
|
|
146
|
-
return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
|
|
147
|
-
|
|
148
|
-
|
|
149
69
|
def _resolve_scan_root(markdown_directory: Path) -> Path:
|
|
150
70
|
"""Return the repository directory whose subtree bounds the code-file search.
|
|
151
71
|
|
|
@@ -248,8 +168,8 @@ def _code_file_reads_variable(code_file: Path, variable_name: str) -> bool | Non
|
|
|
248
168
|
def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
|
|
249
169
|
"""Return each env-var table row whose code file does not read the variable.
|
|
250
170
|
|
|
251
|
-
|
|
252
|
-
|
|
171
|
+
Reads environment-variable tables and headerless edit fragments outside code
|
|
172
|
+
fences. A row counts when its first cell names an UPPER_SNAKE variable and
|
|
253
173
|
a later cell names a code file with a recognized extension. The row drifts when
|
|
254
174
|
that code file resolves under the scan root yet its source never references the
|
|
255
175
|
variable name; a row whose code file does not resolve, or cannot be read, is
|
|
@@ -266,15 +186,7 @@ def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
|
|
|
266
186
|
scan_root = _resolve_scan_root(markdown_directory)
|
|
267
187
|
drift_rows: list[str] = []
|
|
268
188
|
already_reported: set[str] = set()
|
|
269
|
-
|
|
270
|
-
for each_line in content.splitlines():
|
|
271
|
-
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
272
|
-
is_inside_code_fence = not is_inside_code_fence
|
|
273
|
-
continue
|
|
274
|
-
if is_inside_code_fence:
|
|
275
|
-
continue
|
|
276
|
-
if TABLE_ROW_PATTERN.match(each_line) is None:
|
|
277
|
-
continue
|
|
189
|
+
for each_line in iter_env_var_table_rows(content):
|
|
278
190
|
each_finding = _drift_finding_for_row(each_line, scan_root)
|
|
279
191
|
if each_finding is None or each_finding in already_reported:
|
|
280
192
|
continue
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Recognize Markdown environment-variable tables and parse their cells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from hooks_constants.env_var_table_code_drift_constants import (
|
|
9
|
+
ALL_CODE_FILE_EXTENSIONS,
|
|
10
|
+
ALL_GENERIC_ENV_VAR_HEADERS,
|
|
11
|
+
BACKTICK_TOKEN_PATTERN,
|
|
12
|
+
CODE_FENCE_PATTERN,
|
|
13
|
+
ENV_VAR_HEADER_PATTERN,
|
|
14
|
+
ENV_VAR_HEADING_PATTERN,
|
|
15
|
+
ENV_VAR_NAME_PATTERN,
|
|
16
|
+
SEPARATOR_CELL_PATTERN,
|
|
17
|
+
TABLE_ROW_PATTERN,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _row_cells(table_line: str) -> list[str]:
|
|
22
|
+
"""Return the trimmed cells of one markdown table row.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
table_line: A single line that begins with a pipe character.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The text of each pipe-delimited cell, stripped, with the empty leading
|
|
29
|
+
and trailing segments a bounding pipe produces removed.
|
|
30
|
+
"""
|
|
31
|
+
stripped_line = table_line.strip()
|
|
32
|
+
inner = stripped_line.strip("|")
|
|
33
|
+
return [each_cell.strip() for each_cell in inner.split("|")]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _first_backtick_token(cell_text: str) -> str | None:
|
|
37
|
+
"""Return the first backtick-wrapped token in a cell, when it has one.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
cell_text: The trimmed text of a table cell.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The text inside the first pair of backticks, or None when the cell
|
|
44
|
+
carries no backtick-wrapped token.
|
|
45
|
+
"""
|
|
46
|
+
token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
|
|
47
|
+
if token_match is None:
|
|
48
|
+
return None
|
|
49
|
+
inner_text = token_match.group(1).strip()
|
|
50
|
+
return inner_text or None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _env_var_name_in_cell(cell_text: str) -> str | None:
|
|
54
|
+
"""Return the environment-variable name a cell names, when it names one.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
cell_text: The trimmed text of a table cell.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The UPPER_SNAKE variable name inside the first backticks, or None when
|
|
61
|
+
the cell names no variable-shaped token.
|
|
62
|
+
"""
|
|
63
|
+
token = _first_backtick_token(cell_text)
|
|
64
|
+
if token is None:
|
|
65
|
+
return None
|
|
66
|
+
if ENV_VAR_NAME_PATTERN.match(token) is None:
|
|
67
|
+
return None
|
|
68
|
+
return token
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _code_file_reference_in_cell(cell_text: str) -> str | None:
|
|
72
|
+
"""Return the code-file path a cell names, when it names one.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
cell_text: The trimmed text of a table cell.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The relative code-file path inside the first backticks, or None when the
|
|
79
|
+
token carries no recognized code-file extension.
|
|
80
|
+
"""
|
|
81
|
+
token = _first_backtick_token(cell_text)
|
|
82
|
+
if token is None:
|
|
83
|
+
return None
|
|
84
|
+
_, extension = os.path.splitext(token)
|
|
85
|
+
if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
|
|
86
|
+
return None
|
|
87
|
+
return token
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_separator_row(all_cells: list[str]) -> bool:
|
|
91
|
+
"""Return whether every cell is a markdown table header-separator cell.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
all_cells: The trimmed cells of one table row.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True when each cell holds only dashes, colons, and whitespace.
|
|
98
|
+
"""
|
|
99
|
+
return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def iter_env_var_table_rows(content: str) -> Iterator[str]:
|
|
103
|
+
"""Yield environment-table rows and headerless edit fragments outside code fences."""
|
|
104
|
+
is_inside_code_fence = False
|
|
105
|
+
is_environment_table: bool | None = None
|
|
106
|
+
is_environment_section = False
|
|
107
|
+
for each_line in content.splitlines():
|
|
108
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
109
|
+
is_inside_code_fence = not is_inside_code_fence
|
|
110
|
+
is_environment_table = None
|
|
111
|
+
continue
|
|
112
|
+
if is_inside_code_fence:
|
|
113
|
+
continue
|
|
114
|
+
if each_line.lstrip().startswith("#"):
|
|
115
|
+
is_environment_section = ENV_VAR_HEADING_PATTERN.search(each_line) is not None
|
|
116
|
+
if TABLE_ROW_PATTERN.match(each_line) is None:
|
|
117
|
+
is_environment_table = None
|
|
118
|
+
continue
|
|
119
|
+
all_cells = _row_cells(each_line)
|
|
120
|
+
if not all_cells or _is_separator_row(all_cells):
|
|
121
|
+
continue
|
|
122
|
+
if is_environment_table is None:
|
|
123
|
+
header = all_cells[0].strip("`* ")
|
|
124
|
+
is_environment_table = (
|
|
125
|
+
ENV_VAR_HEADER_PATTERN.fullmatch(header) is not None
|
|
126
|
+
or _env_var_name_in_cell(all_cells[0]) is not None
|
|
127
|
+
or (is_environment_section and header.casefold() in ALL_GENERIC_ENV_VAR_HEADERS)
|
|
128
|
+
)
|
|
129
|
+
if is_environment_table:
|
|
130
|
+
yield each_line
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse rewriter: keep Git Bash from converting a ``<rev>:<path>`` argument.
|
|
3
|
+
|
|
4
|
+
Git Bash rewrites a git argument such as ``origin/main:.claude/settings.json``
|
|
5
|
+
into a backslash-separated form joined by a semicolon before git sees it, and
|
|
6
|
+
git then fails with ``fatal: ambiguous argument``. This hook reads the command
|
|
7
|
+
before it runs, finds every token MSYS would convert, and prepends one
|
|
8
|
+
``export MSYS2_ARG_CONV_EXCL='<rev1>:;<rev2>:'; `` naming exactly those
|
|
9
|
+
prefixes. Every other argument in the command keeps converting as before.
|
|
10
|
+
|
|
11
|
+
A token is converted only when the part before its first colon carries a slash
|
|
12
|
+
and the part after that colon opens a repository-root path, so ``HEAD:.gitignore``
|
|
13
|
+
and ``origin/main:packages/app.py`` both reach git untouched and neither is
|
|
14
|
+
collected.
|
|
15
|
+
|
|
16
|
+
Quiet branches, each emitting nothing: a non-Bash tool, a command that already
|
|
17
|
+
names one of the MSYS conversion variables, and a command from which no prefix
|
|
18
|
+
was collected.
|
|
19
|
+
|
|
20
|
+
Hosted by ``blocking/bash_pre_tool_use_dispatcher.py``, which carries the
|
|
21
|
+
``updatedInput`` this hook prints through to the harness.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import shlex
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
32
|
+
if _hooks_root_directory not in sys.path:
|
|
33
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
34
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
|
|
35
|
+
from hooks_constants.msys_path_conversion_advisor_constants import (
|
|
36
|
+
ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.msys_rev_path_rewriter_constants import (
|
|
39
|
+
ALL_CONVERTED_PATH_START_CHARACTERS,
|
|
40
|
+
ALL_REVISION_FORBIDDEN_CHARACTERS,
|
|
41
|
+
ALL_UNCONVERTED_PATH_START_PREFIXES,
|
|
42
|
+
EXCLUSION_EXPORT_TEMPLATE,
|
|
43
|
+
EXCLUSION_PREFIX_JOIN_SEPARATOR,
|
|
44
|
+
GIT_PROGRAM_NAME,
|
|
45
|
+
REVISION_PATH_SEPARATOR,
|
|
46
|
+
REVISION_PATH_SPLIT_COUNT,
|
|
47
|
+
REVISION_SLASH_CHARACTER,
|
|
48
|
+
)
|
|
49
|
+
from hooks_constants.pre_tool_use_allow_output import (
|
|
50
|
+
write_pre_tool_use_allow_to_stdout,
|
|
51
|
+
)
|
|
52
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
53
|
+
from hooks_constants.shell_command_segments import (
|
|
54
|
+
effective_leading_program,
|
|
55
|
+
split_into_segments,
|
|
56
|
+
token_basename,
|
|
57
|
+
)
|
|
58
|
+
except ImportError as import_error:
|
|
59
|
+
raise ImportError(
|
|
60
|
+
"The MSYS revision-path rewriter cannot import its dependencies; "
|
|
61
|
+
"ensure the hooks directory is importable."
|
|
62
|
+
) from import_error
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _all_git_segment_tokens(command: str) -> list[list[str]]:
|
|
66
|
+
"""Return the token lists of the command's segments that run git."""
|
|
67
|
+
try:
|
|
68
|
+
all_command_tokens = shlex.split(command, posix=True)
|
|
69
|
+
except ValueError:
|
|
70
|
+
all_command_tokens = command.split()
|
|
71
|
+
all_git_segments: list[list[str]] = []
|
|
72
|
+
for each_segment in split_into_segments(all_command_tokens):
|
|
73
|
+
program_token = effective_leading_program(each_segment)
|
|
74
|
+
if program_token is None:
|
|
75
|
+
continue
|
|
76
|
+
if token_basename(program_token) == GIT_PROGRAM_NAME:
|
|
77
|
+
all_git_segments.append(each_segment)
|
|
78
|
+
return all_git_segments
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_revision_shaped(revision_text: str) -> bool:
|
|
82
|
+
"""Return True when the text can name a git revision.
|
|
83
|
+
|
|
84
|
+
::
|
|
85
|
+
|
|
86
|
+
origin/main -> True
|
|
87
|
+
HEAD -> False, no slash
|
|
88
|
+
don't touch a/b -> False, a quoted message, not a revision
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
revision_text: The part of a token before its first colon.
|
|
92
|
+
"""
|
|
93
|
+
if not revision_text or REVISION_SLASH_CHARACTER not in revision_text:
|
|
94
|
+
return False
|
|
95
|
+
if any(each_character.isspace() for each_character in revision_text):
|
|
96
|
+
return False
|
|
97
|
+
return not any(
|
|
98
|
+
each_character in revision_text for each_character in ALL_REVISION_FORBIDDEN_CHARACTERS
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _converted_revision_prefix(token: str) -> str | None:
|
|
103
|
+
"""Return the ``<rev>:`` prefix MSYS would convert in this token, or None.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
token: One shell token from a git segment.
|
|
107
|
+
"""
|
|
108
|
+
if REVISION_PATH_SEPARATOR not in token:
|
|
109
|
+
return None
|
|
110
|
+
revision_text, path_text = token.split(REVISION_PATH_SEPARATOR, REVISION_PATH_SPLIT_COUNT)
|
|
111
|
+
if not _is_revision_shaped(revision_text):
|
|
112
|
+
return None
|
|
113
|
+
if not path_text.startswith(ALL_CONVERTED_PATH_START_CHARACTERS):
|
|
114
|
+
return None
|
|
115
|
+
if path_text.startswith(ALL_UNCONVERTED_PATH_START_PREFIXES):
|
|
116
|
+
return None
|
|
117
|
+
return revision_text + REVISION_PATH_SEPARATOR
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _extend_with_new_prefixes(
|
|
121
|
+
all_ordered_prefixes: list[str], all_segment_tokens: list[str]
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Append each convertible prefix this segment adds, skipping repeats.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
all_ordered_prefixes: The prefixes collected so far, appended in place.
|
|
127
|
+
all_segment_tokens: One git segment's shell tokens.
|
|
128
|
+
"""
|
|
129
|
+
for each_token in all_segment_tokens:
|
|
130
|
+
revision_prefix = _converted_revision_prefix(each_token)
|
|
131
|
+
if revision_prefix is not None and revision_prefix not in all_ordered_prefixes:
|
|
132
|
+
all_ordered_prefixes.append(revision_prefix)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def all_exclusion_prefixes(command: str) -> tuple[str, ...]:
|
|
136
|
+
"""Return the ``<rev>:`` prefixes MSYS would convert, deduplicated in order.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
command: The Bash command text the agent is about to run.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Each distinct prefix in first-appearance order, empty when the command
|
|
143
|
+
carries no token Git Bash would convert.
|
|
144
|
+
"""
|
|
145
|
+
all_ordered_prefixes: list[str] = []
|
|
146
|
+
for each_segment in _all_git_segment_tokens(command):
|
|
147
|
+
_extend_with_new_prefixes(all_ordered_prefixes, each_segment)
|
|
148
|
+
return tuple(all_ordered_prefixes)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def command_with_exclusion_export(command: str) -> str:
|
|
152
|
+
"""Return the command prefixed with the surgical exclusion export.
|
|
153
|
+
|
|
154
|
+
Returns the command unchanged when it already names an MSYS conversion
|
|
155
|
+
variable or when no convertible token was found.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
command: The Bash command text the agent is about to run.
|
|
159
|
+
"""
|
|
160
|
+
if any(
|
|
161
|
+
each_variable_name in command
|
|
162
|
+
for each_variable_name in ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES
|
|
163
|
+
):
|
|
164
|
+
return command
|
|
165
|
+
all_prefixes = all_exclusion_prefixes(command)
|
|
166
|
+
if not all_prefixes:
|
|
167
|
+
return command
|
|
168
|
+
export_prefix = EXCLUSION_EXPORT_TEMPLATE.format(
|
|
169
|
+
all_prefixes=EXCLUSION_PREFIX_JOIN_SEPARATOR.join(all_prefixes)
|
|
170
|
+
)
|
|
171
|
+
return export_prefix + command
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> None:
|
|
175
|
+
"""Emit an allow carrying the exclusion-prefixed command, or stay quiet."""
|
|
176
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
177
|
+
if hook_payload is None or hook_payload.get("tool_name") != BASH_TOOL_NAME:
|
|
178
|
+
return
|
|
179
|
+
tool_input = hook_payload.get("tool_input")
|
|
180
|
+
if not isinstance(tool_input, dict):
|
|
181
|
+
return
|
|
182
|
+
command = tool_input.get("command", "")
|
|
183
|
+
if not isinstance(command, str):
|
|
184
|
+
return
|
|
185
|
+
rewritten_command = command_with_exclusion_export(command)
|
|
186
|
+
if rewritten_command == command:
|
|
187
|
+
return
|
|
188
|
+
write_pre_tool_use_allow_to_stdout({**tool_input, "command": rewritten_command})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
main()
|
|
@@ -18,10 +18,9 @@ if _hooks_directory not in sys.path:
|
|
|
18
18
|
sys.path.insert(0, _hooks_directory)
|
|
19
19
|
|
|
20
20
|
from hooks_constants.local_identity import nas_host # noqa: E402
|
|
21
|
-
from hooks_constants.pii_prevention_constants import (
|
|
21
|
+
from hooks_constants.pii_prevention_constants import (
|
|
22
22
|
ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES,
|
|
23
23
|
ALL_EXACT_LEGAL_NOTICE_BASENAMES,
|
|
24
|
-
ALL_HOME_DIRECTORY_PATH_MARKERS,
|
|
25
24
|
ALL_PLACEHOLDER_HOME_USERNAMES,
|
|
26
25
|
ALL_REDACTED_PREVIEW_CATEGORIES,
|
|
27
26
|
ALL_RFC1918_NETWORK_CIDRS,
|
|
@@ -37,6 +36,8 @@ from hooks_constants.pii_prevention_constants import ( # noqa: E402
|
|
|
37
36
|
CONFTEST_BASENAME,
|
|
38
37
|
EMAIL_PATTERN,
|
|
39
38
|
HOME_PATH_PATTERN,
|
|
39
|
+
HOME_USERNAME_MARKER_PATTERN,
|
|
40
|
+
INTERPOLATED_HOME_USERNAME_PATTERN,
|
|
40
41
|
IPV4_PATTERN,
|
|
41
42
|
IPV4_VERSION_NUMBER,
|
|
42
43
|
MAXIMUM_FINDINGS_PER_SCAN,
|
|
@@ -90,9 +91,7 @@ def is_path_exempt_from_pii_scan(file_path: str) -> bool:
|
|
|
90
91
|
return False
|
|
91
92
|
normalized_path = file_path.replace("\\", "/").lower()
|
|
92
93
|
path_for_suffix_match = (
|
|
93
|
-
normalized_path
|
|
94
|
-
if normalized_path.startswith("/")
|
|
95
|
-
else f"/{normalized_path}"
|
|
94
|
+
normalized_path if normalized_path.startswith("/") else f"/{normalized_path}"
|
|
96
95
|
)
|
|
97
96
|
basename = os.path.basename(file_path)
|
|
98
97
|
basename_lower = basename.lower()
|
|
@@ -106,9 +105,7 @@ def is_path_exempt_from_pii_scan(file_path: str) -> bool:
|
|
|
106
105
|
or basename_lower.endswith(TEST_MODULE_BASENAME_SUFFIX)
|
|
107
106
|
):
|
|
108
107
|
return True
|
|
109
|
-
if TESTS_PATH_SEGMENT in normalized_path or normalized_path.startswith(
|
|
110
|
-
TESTS_PATH_PREFIX
|
|
111
|
-
):
|
|
108
|
+
if TESTS_PATH_SEGMENT in normalized_path or normalized_path.startswith(TESTS_PATH_PREFIX):
|
|
112
109
|
return True
|
|
113
110
|
if basename_lower.endswith(ALL_SOURCE_TEST_FILE_SUFFIXES) and (
|
|
114
111
|
SPEC_BASENAME_MARKER in basename_lower or TEST_BASENAME_MARKER in basename_lower
|
|
@@ -130,11 +127,7 @@ def _redact_sensitive_preview(matched_text: str) -> str:
|
|
|
130
127
|
return REDACTED_SHORT_PREVIEW
|
|
131
128
|
prefix_length = REDACTED_PREVIEW_PREFIX_LENGTH
|
|
132
129
|
suffix_length = REDACTED_PREVIEW_SUFFIX_LENGTH
|
|
133
|
-
return
|
|
134
|
-
matched_text[:prefix_length]
|
|
135
|
-
+ REDACTED_PREVIEW_ELLIPSIS
|
|
136
|
-
+ matched_text[-suffix_length:]
|
|
137
|
-
)
|
|
130
|
+
return matched_text[:prefix_length] + REDACTED_PREVIEW_ELLIPSIS + matched_text[-suffix_length:]
|
|
138
131
|
|
|
139
132
|
|
|
140
133
|
def _build_preview(matched_text: str, category: str) -> str:
|
|
@@ -161,8 +154,7 @@ def _is_safe_email_domain(domain_name: str) -> bool:
|
|
|
161
154
|
if domain_name in ALL_SAFE_EMAIL_DOMAINS:
|
|
162
155
|
return True
|
|
163
156
|
return any(
|
|
164
|
-
domain_name.endswith("." + each_safe_domain)
|
|
165
|
-
for each_safe_domain in ALL_SAFE_EMAIL_DOMAINS
|
|
157
|
+
domain_name.endswith("." + each_safe_domain) for each_safe_domain in ALL_SAFE_EMAIL_DOMAINS
|
|
166
158
|
)
|
|
167
159
|
|
|
168
160
|
|
|
@@ -180,8 +172,8 @@ def _home_username_offset(matched_path: str) -> tuple[int, str] | None:
|
|
|
180
172
|
|
|
181
173
|
C:\\Users\\Users\\file.txt -> (9, "Users") the second Users, not the first
|
|
182
174
|
|
|
183
|
-
|
|
184
|
-
|
|
175
|
+
The marker match consumes repeated separators, so the username starts after
|
|
176
|
+
both ordinary and escaped Windows path separators.
|
|
185
177
|
|
|
186
178
|
Args:
|
|
187
179
|
matched_path: The matched home-path substring.
|
|
@@ -189,18 +181,14 @@ def _home_username_offset(matched_path: str) -> tuple[int, str] | None:
|
|
|
189
181
|
Returns:
|
|
190
182
|
The username's start index and value, or None when no marker is found.
|
|
191
183
|
"""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if not username:
|
|
201
|
-
return None
|
|
202
|
-
return username_start, username
|
|
203
|
-
return None
|
|
184
|
+
marker = HOME_USERNAME_MARKER_PATTERN.search(matched_path)
|
|
185
|
+
if marker is None:
|
|
186
|
+
return None
|
|
187
|
+
username_start = marker.end()
|
|
188
|
+
username = matched_path[username_start:].replace("\\", "/").split("/", 1)[0]
|
|
189
|
+
if not username:
|
|
190
|
+
return None
|
|
191
|
+
return username_start, username
|
|
204
192
|
|
|
205
193
|
|
|
206
194
|
def _username_from_home_path(matched_path: str) -> str | None:
|
|
@@ -226,6 +214,8 @@ def _redact_home_path_username(matched_text: str) -> str:
|
|
|
226
214
|
|
|
227
215
|
|
|
228
216
|
def _is_placeholder_home_username(username: str) -> bool:
|
|
217
|
+
if INTERPOLATED_HOME_USERNAME_PATTERN.fullmatch(username) is not None:
|
|
218
|
+
return True
|
|
229
219
|
if ANGLE_BRACKET_PLACEHOLDER_PATTERN.match(username) is not None:
|
|
230
220
|
return True
|
|
231
221
|
lowered_username = username.lower()
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""Tests for the
|
|
1
|
+
"""Tests for what the Bash and PowerShell PreToolUse chain costs to run.
|
|
2
|
+
|
|
3
|
+
A Bash tool call starts one interpreter, the dispatcher's own. Each roster
|
|
4
|
+
entry then runs in that process through runpy, so the roster's exact content is
|
|
5
|
+
the chain's cost after that single start. One allow-and-rewrite hook is the
|
|
6
|
+
whole roster; a blocking hook added here fails this test.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
import sys
|
|
4
10
|
from pathlib import Path
|
|
@@ -9,8 +15,15 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
9
15
|
|
|
10
16
|
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
11
17
|
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
18
|
+
ALL_BASH_ONLY_TOOL_NAMES,
|
|
19
|
+
BashHostedHookEntry,
|
|
12
20
|
)
|
|
13
21
|
|
|
14
22
|
|
|
15
|
-
def
|
|
16
|
-
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
23
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_no_blocking_hook() -> None:
|
|
24
|
+
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
25
|
+
BashHostedHookEntry(
|
|
26
|
+
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
27
|
+
applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
|
|
28
|
+
),
|
|
29
|
+
)
|
|
@@ -28,6 +28,7 @@ from tdd_enforcer_parts import content_hash_store
|
|
|
28
28
|
_ALL_EXPECTED_ROSTER_PATHS = [
|
|
29
29
|
"observability/test_failure_recorder.py",
|
|
30
30
|
"advisory/pr_done_reminder.py",
|
|
31
|
+
"advisory/msys_path_conversion_advisor.py",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
|
|
@@ -66,6 +67,7 @@ def test_dispatch_forwards_hosted_hook_additional_context_as_one_payload(
|
|
|
66
67
|
"pr_done_reminder.py": json.dumps(
|
|
67
68
|
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "PR #42"}}
|
|
68
69
|
),
|
|
70
|
+
"msys_path_conversion_advisor.py": "",
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
|