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.
Files changed (86) hide show
  1. package/bin/install.mjs +0 -1
  2. package/bin/install.policy-lint.test.mjs +1 -2
  3. package/bin/install.test.mjs +4 -4
  4. package/bin/pstack.mjs +24 -4
  5. package/bin/pstack.test.mjs +20 -10
  6. package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
  7. package/hooks/advisory/pr_done_reminder.py +7 -13
  8. package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
  9. package/hooks/advisory/test_pr_done_reminder.py +1 -0
  10. package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
  11. package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
  12. package/hooks/blocking/env_var_table_rows.py +130 -0
  13. package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
  14. package/hooks/blocking/pii_scanner.py +19 -29
  15. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
  17. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
  18. package/hooks/blocking/test_env_var_table_rows.py +100 -0
  19. package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
  20. package/hooks/blocking/test_pii_scanner.py +34 -24
  21. package/hooks/hooks.json +10 -0
  22. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
  23. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
  24. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
  25. package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
  26. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
  27. package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
  28. package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
  29. package/hooks/hooks_constants/post_tool_use_context.py +34 -0
  30. package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
  31. package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
  32. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
  33. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
  34. package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
  35. package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
  36. package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
  37. package/hooks/observability/test_failure_recorder.py +3 -1
  38. package/package.json +1 -1
  39. package/rules/ask-user-question-required.md +3 -3
  40. package/rules/code-standards.md +3 -2
  41. package/rules/confirm-implementation-forks.md +2 -0
  42. package/rules/destructive-commands.md +19 -19
  43. package/rules/doc-inventory-integrity.md +4 -4
  44. package/rules/docstring-prose-matches-implementation.md +2 -2
  45. package/rules/failure-blast-radius.md +4 -2
  46. package/rules/filesystem-search.md +1 -1
  47. package/rules/git-workflow.md +34 -0
  48. package/rules/long-horizon-autonomy.md +2 -0
  49. package/rules/nas-ssh-invocation.md +3 -3
  50. package/rules/no-cross-skill-duplicate-helpers.md +6 -6
  51. package/rules/orphan-css-class.md +5 -5
  52. package/rules/paired-test-coverage.md +6 -6
  53. package/rules/plain-illustrative-docstrings.md +3 -3
  54. package/rules/re-stage-before-commit.md +7 -7
  55. package/rules/retired-hook-prose.md +16 -0
  56. package/rules/shell-invocation.md +17 -1
  57. package/rules/testing.md +3 -5
  58. package/rules/verify-runtime-state.md +1 -1
  59. package/rules/windows-filesystem-safe.md +2 -2
  60. package/rules/workflow-substitution-slots.md +1 -1
  61. package/scripts/durable_post_lint_config/config/constants.py +0 -3
  62. package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
  63. package/scripts/policy_lint/adapter_configuration.py +24 -0
  64. package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
  65. package/scripts/policy_lint/adapters.py +36 -1
  66. package/scripts/policy_lint/config/constants.py +74 -0
  67. package/scripts/policy_lint/registry.py +5 -0
  68. package/scripts/policy_lint/selection.py +37 -18
  69. package/scripts/policy_lint/selection_git.py +6 -2
  70. package/scripts/pstack-adapters/common.md +1 -1
  71. package/scripts/repository_checks/config/constants.py +7 -17
  72. package/scripts/repository_checks/config/email_exemptions.py +10 -0
  73. package/scripts/repository_checks/email_exemptions.py +96 -0
  74. package/scripts/repository_checks/env_var_documentation.py +1 -9
  75. package/scripts/repository_checks/tracked_secrets.py +10 -4
  76. package/scripts/test_durable_post_lint.py +42 -9
  77. package/scripts/tests/test_adapter_configuration.py +8 -0
  78. package/scripts/tests/test_email_exemptions.py +121 -0
  79. package/scripts/tests/test_env_var_documentation.py +26 -0
  80. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
  81. package/scripts/tests/test_policy_lint_selection.py +107 -0
  82. package/scripts/tests/test_tracked_secrets.py +13 -0
  83. package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
  84. package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
  85. package/hooks/hooks_constants/test_text_stripping.py +0 -39
  86. 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. A hook that prints nothing adds nothing.
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
- payload = {
102
- HOOK_SPECIFIC_OUTPUT_KEY: {
103
- "hookEventName": POST_TOOL_USE_HOOK_EVENT_NAME,
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 hooks_constants.env_var_table_code_drift_constants import ( # noqa: E402
29
- ALL_CODE_FILE_EXTENSIONS,
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
- Walks the markdown content line by line, skipping lines inside a fenced code
252
- block. A table row counts when its first cell names an UPPER_SNAKE variable and
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
- is_inside_code_fence = False
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 ( # noqa: E402
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
- Replacing backslashes with forward slashes preserves length, so the marker
184
- offset computed on the normalized path indexes *matched_path* directly.
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
- normalized_path = matched_path.replace("\\", "/")
193
- lowered_path = normalized_path.lower()
194
- for each_marker in ALL_HOME_DIRECTORY_PATH_MARKERS:
195
- marker_index = lowered_path.find(each_marker)
196
- if marker_index < 0:
197
- continue
198
- username_start = marker_index + len(each_marker)
199
- username = normalized_path[username_start:].split("/", 1)[0]
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 retired Bash and PowerShell blocking roster."""
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 test_retired_bash_roster_has_no_effective_blocking_hooks() -> None:
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: