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
@@ -125,9 +125,10 @@ def test_production_entrypoint_denies_drifted_table(tmp_path: Path) -> None:
125
125
  hook_specific_output = parsed_payload["hookSpecificOutput"]
126
126
  assert completed.returncode == 0, completed.stderr
127
127
  assert hook_specific_output["permissionDecision"] == "deny"
128
- assert "GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py" in hook_specific_output[
129
- "permissionDecisionReason"
130
- ]
128
+ assert (
129
+ "GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py"
130
+ in hook_specific_output["permissionDecisionReason"]
131
+ )
131
132
  assert_hook_deny_log_contains(tmp_path, "env_var_table_code_drift_blocker.py")
132
133
 
133
134
 
@@ -159,3 +160,80 @@ def test_ignores_row_whose_second_cell_is_not_a_code_file(tmp_path: Path) -> Non
159
160
  "| `SOME_FLAG` | `enables the thing` | off |\n"
160
161
  )
161
162
  assert find_drift_rows(content, doc_path.parent) == []
163
+
164
+
165
+ def test_constant_tables_do_not_claim_environment_consumers(tmp_path: Path) -> None:
166
+ _anchor_repo_root(tmp_path)
167
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
168
+ content = (
169
+ "| Symbol | Imported by |\n"
170
+ "| --- | --- |\n"
171
+ "| `PROCESSING_VISIBLE` | `workflow.py` |\n\n"
172
+ "| Component | Location | Purpose |\n"
173
+ "| --- | --- | --- |\n"
174
+ "| `ACCOUNT_LINK_PRIMARY` | `workflow.py` | Selector |\n\n"
175
+ "| Constant | Value | Source |\n"
176
+ "| --- | --- | --- |\n"
177
+ "| `UPLOAD_DELAY_SECONDS` | 5 | `workflow.py` |\n"
178
+ )
179
+ assert find_drift_rows(content, tmp_path) == []
180
+
181
+
182
+ def test_environment_table_after_constants_still_reports_drift(tmp_path: Path) -> None:
183
+ _anchor_repo_root(tmp_path)
184
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
185
+ content = (
186
+ "| Constant | Source |\n"
187
+ "| --- | --- |\n"
188
+ "| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n\n"
189
+ "| Environment variable | Consumer |\n"
190
+ "| --- | --- |\n"
191
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
192
+ )
193
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
194
+
195
+
196
+ def test_environment_section_supports_name_header(tmp_path: Path) -> None:
197
+ _anchor_repo_root(tmp_path)
198
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
199
+ content = (
200
+ "## Environment variables\n\n"
201
+ "| Name | Consumer |\n"
202
+ "| --- | --- |\n"
203
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
204
+ )
205
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
206
+
207
+
208
+ def test_headerless_edit_fragment_still_reports_drift(tmp_path: Path) -> None:
209
+ _anchor_repo_root(tmp_path)
210
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
211
+ content = "| `UPLOAD_TOKEN` | `workflow.py` |\n"
212
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
213
+
214
+
215
+ def test_descriptive_environment_header_reports_drift(tmp_path: Path) -> None:
216
+ _anchor_repo_root(tmp_path)
217
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
218
+ content = (
219
+ "| Environment Variable Name | Consumer |\n"
220
+ "| --- | --- |\n"
221
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
222
+ )
223
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
224
+
225
+
226
+ def test_explicit_source_header_overrides_environment_section(tmp_path: Path) -> None:
227
+ _anchor_repo_root(tmp_path)
228
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
229
+ content = (
230
+ "## Environment variables\n\n"
231
+ "| Name | Consumer |\n"
232
+ "| --- | --- |\n"
233
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n\n"
234
+ "The source also defines a delay constant.\n\n"
235
+ "| Constant | Source |\n"
236
+ "| --- | --- |\n"
237
+ "| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n"
238
+ )
239
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
@@ -0,0 +1,100 @@
1
+ """Tests for environment-variable table row recognition.
2
+
3
+ Each test drives ``iter_env_var_table_rows`` against markdown text and asserts
4
+ the exact rows it yields. The behavior under test is table context: which
5
+ markdown tables read as environment-variable summaries, and which read as
6
+ source, constant, or component tables that name no environment consumer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from env_var_table_rows import iter_env_var_table_rows
12
+
13
+
14
+ def test_environment_variable_header_yields_data_rows() -> None:
15
+ content = (
16
+ "| Environment variable | Consumer |\n| --- | --- |\n| `UPLOAD_TOKEN` | `workflow.py` |\n"
17
+ )
18
+ assert list(iter_env_var_table_rows(content)) == [
19
+ "| Environment variable | Consumer |",
20
+ "| `UPLOAD_TOKEN` | `workflow.py` |",
21
+ ]
22
+
23
+
24
+ def test_constant_table_yields_nothing() -> None:
25
+ content = (
26
+ "| Constant | Value | Source |\n"
27
+ "| --- | --- | --- |\n"
28
+ "| `UPLOAD_DELAY_SECONDS` | 5 | `workflow.py` |\n"
29
+ )
30
+ assert list(iter_env_var_table_rows(content)) == []
31
+
32
+
33
+ def test_component_table_yields_nothing() -> None:
34
+ content = (
35
+ "| Component | Location | Purpose |\n"
36
+ "| --- | --- | --- |\n"
37
+ "| `ACCOUNT_LINK_PRIMARY` | `workflow.py` | Selector |\n"
38
+ )
39
+ assert list(iter_env_var_table_rows(content)) == []
40
+
41
+
42
+ def test_generic_name_header_under_environment_heading_yields_rows() -> None:
43
+ content = (
44
+ "## Environment variables\n\n"
45
+ "| Name | Consumer |\n"
46
+ "| --- | --- |\n"
47
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
48
+ )
49
+ assert list(iter_env_var_table_rows(content)) == [
50
+ "| Name | Consumer |",
51
+ "| `UPLOAD_TOKEN` | `workflow.py` |",
52
+ ]
53
+
54
+
55
+ def test_generic_name_header_without_environment_heading_yields_nothing() -> None:
56
+ content = (
57
+ "## Selectors\n\n"
58
+ "| Name | Consumer |\n"
59
+ "| --- | --- |\n"
60
+ "| `ACCOUNT_LINK_PRIMARY` | `workflow.py` |\n"
61
+ )
62
+ assert list(iter_env_var_table_rows(content)) == []
63
+
64
+
65
+ def test_headerless_fragment_naming_a_variable_yields_the_row() -> None:
66
+ content = "| `UPLOAD_TOKEN` | `workflow.py` |\n"
67
+ assert list(iter_env_var_table_rows(content)) == ["| `UPLOAD_TOKEN` | `workflow.py` |"]
68
+
69
+
70
+ def test_rows_inside_a_code_fence_yield_nothing() -> None:
71
+ content = (
72
+ "```markdown\n"
73
+ "| Environment variable | Consumer |\n"
74
+ "| --- | --- |\n"
75
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
76
+ "```\n"
77
+ )
78
+ assert list(iter_env_var_table_rows(content)) == []
79
+
80
+
81
+ def test_constant_table_after_an_environment_table_yields_nothing_extra() -> None:
82
+ content = (
83
+ "| Environment variable | Consumer |\n"
84
+ "| --- | --- |\n"
85
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n\n"
86
+ "| Constant | Source |\n"
87
+ "| --- | --- |\n"
88
+ "| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n"
89
+ )
90
+ assert list(iter_env_var_table_rows(content)) == [
91
+ "| Environment variable | Consumer |",
92
+ "| `UPLOAD_TOKEN` | `workflow.py` |",
93
+ ]
94
+
95
+
96
+ def test_separator_row_is_never_yielded() -> None:
97
+ content = (
98
+ "| Environment variable | Consumer |\n| :--- | ---: |\n| `UPLOAD_TOKEN` | `workflow.py` |\n"
99
+ )
100
+ assert "| :--- | ---: |" not in list(iter_env_var_table_rows(content))
@@ -0,0 +1,141 @@
1
+ """Tests for msys_rev_path_rewriter, the MSYS <rev>:<path> exclusion rewriter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from io import StringIO
7
+ from unittest.mock import patch
8
+
9
+ import msys_rev_path_rewriter as rewriter
10
+
11
+ SETTINGS_SHOW_COMMAND = "git show origin/main:.claude/settings.json"
12
+ SETTINGS_SHOW_REWRITTEN = (
13
+ "export MSYS2_ARG_CONV_EXCL='origin/main:'; git show origin/main:.claude/settings.json"
14
+ )
15
+
16
+
17
+ def _stdout_from_main(payload: dict[str, object]) -> str:
18
+ """Return what main() wrote to stdout for one hook payload."""
19
+ captured_stdout = StringIO()
20
+ with patch("sys.stdin", StringIO(json.dumps(payload))), patch("sys.stdout", captured_stdout):
21
+ try:
22
+ rewriter.main()
23
+ except SystemExit:
24
+ pass
25
+ return captured_stdout.getvalue()
26
+
27
+
28
+ def test_settings_path_after_slashed_revision_yields_one_prefix() -> None:
29
+ assert rewriter.all_exclusion_prefixes(SETTINGS_SHOW_COMMAND) == ("origin/main:",)
30
+
31
+
32
+ def test_settings_path_command_gains_the_surgical_export_prefix() -> None:
33
+ assert rewriter.command_with_exclusion_export(SETTINGS_SHOW_COMMAND) == SETTINGS_SHOW_REWRITTEN
34
+
35
+
36
+ def test_unslashed_revision_beside_a_slashed_one_stays_out_of_the_prefixes() -> None:
37
+ assert rewriter.all_exclusion_prefixes(
38
+ "git diff origin/main:.gitignore HEAD:.gitignore"
39
+ ) == ("origin/main:",)
40
+
41
+
42
+ def test_two_slashed_revisions_join_with_a_semicolon_in_first_appearance_order() -> None:
43
+ assert rewriter.command_with_exclusion_export(
44
+ "git diff origin/main:.gitignore origin/develop:.gitignore"
45
+ ) == (
46
+ "export MSYS2_ARG_CONV_EXCL='origin/main:;origin/develop:'; "
47
+ "git diff origin/main:.gitignore origin/develop:.gitignore"
48
+ )
49
+
50
+
51
+ def test_one_slashed_revision_used_twice_yields_one_prefix() -> None:
52
+ assert rewriter.all_exclusion_prefixes(
53
+ "git diff origin/main:.gitignore origin/main:.gitattributes"
54
+ ) == ("origin/main:",)
55
+
56
+
57
+ def test_dot_file_after_the_colon_is_collected() -> None:
58
+ assert rewriter.all_exclusion_prefixes("git show origin/main:.gitignore") == ("origin/main:",)
59
+
60
+
61
+ def test_plain_directory_path_after_the_colon_is_left_alone() -> None:
62
+ command = "git show origin/main:packages/app.py"
63
+ assert rewriter.all_exclusion_prefixes(command) == ()
64
+ assert rewriter.command_with_exclusion_export(command) == command
65
+
66
+
67
+ def test_revision_without_a_slash_is_left_alone() -> None:
68
+ command = "git show HEAD:.claude/settings.json"
69
+ assert rewriter.all_exclusion_prefixes(command) == ()
70
+ assert rewriter.command_with_exclusion_export(command) == command
71
+
72
+
73
+ def test_dot_slash_path_after_the_colon_is_left_alone() -> None:
74
+ assert rewriter.all_exclusion_prefixes("git show origin/main:./settings.json") == ()
75
+
76
+
77
+ def test_parent_directory_path_after_the_colon_is_left_alone() -> None:
78
+ assert rewriter.all_exclusion_prefixes("git show origin/main:../settings.json") == ()
79
+
80
+
81
+ def test_command_already_naming_the_exclusion_variable_is_left_alone() -> None:
82
+ command = "MSYS2_ARG_CONV_EXCL='*' git show origin/main:.gitignore"
83
+ assert rewriter.command_with_exclusion_export(command) == command
84
+
85
+
86
+ def test_command_already_naming_the_path_conversion_variable_is_left_alone() -> None:
87
+ command = "export MSYS_NO_PATHCONV=1; git show origin/main:.gitignore"
88
+ assert rewriter.command_with_exclusion_export(command) == command
89
+
90
+
91
+ def test_colon_token_in_a_non_git_segment_is_left_alone() -> None:
92
+ assert rewriter.all_exclusion_prefixes("echo origin/main:.gitignore") == ()
93
+
94
+
95
+ def test_quoted_message_token_carrying_a_slash_and_a_dot_is_left_alone() -> None:
96
+ command = "git commit -m \"don't touch a/b:.x\""
97
+ assert rewriter.all_exclusion_prefixes(command) == ()
98
+ assert rewriter.command_with_exclusion_export(command) == command
99
+
100
+
101
+ def test_url_token_in_a_curl_command_is_left_alone() -> None:
102
+ assert rewriter.all_exclusion_prefixes("curl https://example.com/x") == ()
103
+
104
+
105
+ def test_git_segment_after_a_non_git_segment_still_contributes() -> None:
106
+ assert rewriter.all_exclusion_prefixes(
107
+ "echo start && git show origin/main:.gitignore"
108
+ ) == ("origin/main:",)
109
+
110
+
111
+ def test_main_emits_an_allow_carrying_the_rewritten_command() -> None:
112
+ emitted_text = _stdout_from_main(
113
+ {
114
+ "tool_name": "Bash",
115
+ "tool_input": {"command": SETTINGS_SHOW_COMMAND, "description": "read settings"},
116
+ }
117
+ )
118
+ assert json.loads(emitted_text) == {
119
+ "hookSpecificOutput": {
120
+ "hookEventName": "PreToolUse",
121
+ "permissionDecision": "allow",
122
+ "updatedInput": {
123
+ "command": SETTINGS_SHOW_REWRITTEN,
124
+ "description": "read settings",
125
+ },
126
+ }
127
+ }
128
+
129
+
130
+ def test_main_emits_nothing_for_a_non_bash_tool() -> None:
131
+ emitted_text = _stdout_from_main(
132
+ {"tool_name": "PowerShell", "tool_input": {"command": SETTINGS_SHOW_COMMAND}}
133
+ )
134
+ assert emitted_text == ""
135
+
136
+
137
+ def test_main_emits_nothing_for_a_command_with_no_collected_prefix() -> None:
138
+ emitted_text = _stdout_from_main(
139
+ {"tool_name": "Bash", "tool_input": {"command": "git show HEAD:.gitignore"}}
140
+ )
141
+ assert emitted_text == ""
@@ -55,17 +55,13 @@ def test_allows_hygiene_placeholder_username_example() -> None:
55
55
 
56
56
 
57
57
  def test_flags_unix_home_path() -> None:
58
- all_unix_hits = scan_text_for_pii(
59
- "config lives in /Users/fixture_real_user/.config"
60
- )
58
+ all_unix_hits = scan_text_for_pii("config lives in /Users/fixture_real_user/.config")
61
59
  assert any(each.category == "home-path" for each in all_unix_hits)
62
60
 
63
61
 
64
62
  def test_flags_raw_escaped_windows_home_path() -> None:
65
63
  all_home_hits = scan_text_for_pii(r"path is C:\\Users\\concrete-user\\file")
66
- assert [each.matched_text for each in all_home_hits] == [
67
- r"C:\\Users\\concrete-user"
68
- ]
64
+ assert [each.matched_text for each in all_home_hits] == [r"C:\\Users\\concrete-user"]
69
65
 
70
66
 
71
67
  def test_flags_unc_windows_home_path_at_each_share_depth() -> None:
@@ -82,9 +78,7 @@ def test_flags_unc_windows_home_path_at_each_share_depth() -> None:
82
78
 
83
79
  def test_flags_forward_slash_unc_home_paths() -> None:
84
80
  all_home_hits = scan_text_for_pii(
85
- "path is //server/Users/concrete-user/file"
86
- "\n"
87
- "path is //server/share/Users/other-user/file"
81
+ "path is //server/Users/concrete-user/file\npath is //server/share/Users/other-user/file"
88
82
  )
89
83
  assert [each.matched_text for each in all_home_hits] == [
90
84
  "//server/Users/concrete-user",
@@ -112,9 +106,7 @@ def test_flags_absolute_and_file_uri_home_paths() -> None:
112
106
  all_home_hits += scan_text_for_pii("path is file:///home/concrete-user/file")
113
107
  all_home_hits += scan_text_for_pii("path is file://server/home/concrete-user/file")
114
108
  all_home_hits += scan_text_for_pii("path is FILE://server/home/other-user/file")
115
- all_home_hits += scan_text_for_pii(
116
- "path is FILE://server/share/home/third-user/file"
117
- )
109
+ all_home_hits += scan_text_for_pii("path is FILE://server/share/home/third-user/file")
118
110
  assert [each.matched_text for each in all_home_hits] == [
119
111
  "/home/concrete-user",
120
112
  "file:///home/concrete-user",
@@ -191,9 +183,7 @@ def test_flags_github_token_aws_key_and_pem_header() -> None:
191
183
  def test_secret_and_email_previews_are_redacted() -> None:
192
184
  all_secret_hits = scan_text_for_pii(f"export TOKEN={SYNTHETIC_GITHUB_TOKEN}")
193
185
  all_email_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
194
- secret_finding = next(
195
- each for each in all_secret_hits if each.category == "secret"
196
- )
186
+ secret_finding = next(each for each in all_secret_hits if each.category == "secret")
197
187
  email_finding = next(each for each in all_email_hits if each.category == "email")
198
188
  assert secret_finding.matched_text == SYNTHETIC_GITHUB_TOKEN
199
189
  assert SYNTHETIC_GITHUB_TOKEN not in secret_finding.preview
@@ -217,9 +207,7 @@ def test_flags_genuine_human_home_username() -> None:
217
207
 
218
208
  def test_short_nonsafe_email_preview_is_fully_redacted() -> None:
219
209
  all_email_hits = scan_text_for_pii("reach owner@acme.io")
220
- email_finding = next(
221
- each for each in all_email_hits if each.category == "email"
222
- )
210
+ email_finding = next(each for each in all_email_hits if each.category == "email")
223
211
  assert email_finding.matched_text == "owner@acme.io"
224
212
  assert email_finding.preview == "[redacted]"
225
213
  assert "owner" not in email_finding.preview
@@ -227,9 +215,7 @@ def test_short_nonsafe_email_preview_is_fully_redacted() -> None:
227
215
 
228
216
  def test_home_path_preview_redacts_only_the_username() -> None:
229
217
  all_home_hits = scan_text_for_pii(r"path is C:\Users\johnsmith\secret.txt")
230
- home_finding = next(
231
- each for each in all_home_hits if each.category == "home-path"
232
- )
218
+ home_finding = next(each for each in all_home_hits if each.category == "home-path")
233
219
  assert "johnsmith" not in home_finding.preview
234
220
  assert "[redacted]" in home_finding.preview
235
221
  assert "Users" in home_finding.preview
@@ -237,9 +223,7 @@ def test_home_path_preview_redacts_only_the_username() -> None:
237
223
 
238
224
  def test_home_path_preview_redacts_home_segment_when_name_repeats_earlier() -> None:
239
225
  all_home_hits = scan_text_for_pii(r"path is C:\Users\Users\secret.txt")
240
- home_finding = next(
241
- each for each in all_home_hits if each.category == "home-path"
242
- )
226
+ home_finding = next(each for each in all_home_hits if each.category == "home-path")
243
227
  assert home_finding.preview == r"C:\Users\[redacted]"
244
228
 
245
229
 
@@ -269,3 +253,29 @@ def test_path_exemptions_for_tests_license_and_self_modules() -> None:
269
253
  def test_empty_path_still_scans_payload_text() -> None:
270
254
  all_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
271
255
  assert any(each.category == "email" for each in all_hits)
256
+
257
+
258
+ def test_home_match_stops_at_source_string_boundary() -> None:
259
+ findings = scan_text_for_pii('path = "C:/Users/concrete-user"\nnext_path = "C:/data"')
260
+ assert [(item.category, item.matched_text) for item in findings] == [
261
+ ("home-path", "C:/Users/concrete-user")
262
+ ]
263
+
264
+
265
+ def test_allows_interpolated_home_username() -> None:
266
+ assert scan_text_for_pii('path = f"C:/Users/{username}/Documents"') == []
267
+ assert scan_text_for_pii('path = f"C:/Users/{username}"\nnext_path = "C:/data"') == []
268
+
269
+
270
+ def test_template_username_does_not_exempt_literal_username() -> None:
271
+ findings = scan_text_for_pii('path = "C:/Users/concrete-user{suffix}/Documents"')
272
+ assert [item.category for item in findings] == ["home-path"]
273
+
274
+
275
+ def test_preserves_apostrophes_and_parentheses_in_usernames() -> None:
276
+ findings = scan_text_for_pii("C:/Users/O'Neil/Documents\nC:/Users/Jane (Work)/Documents")
277
+ assert [item.matched_text for item in findings] == ["C:/Users/O'Neil", "C:/Users/Jane (Work)"]
278
+
279
+
280
+ def test_escaped_placeholder_username_uses_complete_separator() -> None:
281
+ assert scan_text_for_pii(r"C:\\Users\\example\\Documents") == []
package/hooks/hooks.json CHANGED
@@ -12,6 +12,16 @@
12
12
  }
13
13
  ]
14
14
  },
15
+ {
16
+ "matcher": "Bash",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/bash_pre_tool_use_dispatcher.py",
21
+ "timeout": 60
22
+ }
23
+ ]
24
+ },
15
25
  {
16
26
  "matcher": "EnterWorktree",
17
27
  "hooks": [
@@ -5,6 +5,10 @@ finishes. Reuses ``BashHostedHookEntry`` and the Bash-only tool-name set from
5
5
  ``bash_pre_tool_use_dispatcher_constants`` -- the entry shape and the tool-name
6
6
  membership question are identical on the PostToolUse side, so this module adds
7
7
  no second copy of either.
8
+
9
+ Also the one home for the harness PostToolUse output contract every hook on
10
+ this roster shares: the exit-code prefix a failed call's response text opens
11
+ with, and the three payload keys that carry a context note back to the agent.
8
12
  """
9
13
 
10
14
  from __future__ import annotations
@@ -17,8 +21,10 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
17
21
 
18
22
  __all__ = [
19
23
  "ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES",
24
+ "EXIT_CODE_ERROR_PREFIX",
20
25
  "POST_TOOL_USE_HOOK_EVENT_NAME",
21
26
  "HOOK_SPECIFIC_OUTPUT_KEY",
27
+ "HOOK_EVENT_NAME_KEY",
22
28
  "ADDITIONAL_CONTEXT_KEY",
23
29
  "ADDITIONAL_CONTEXT_JOIN_SEPARATOR",
24
30
  ]
@@ -26,9 +32,12 @@ __all__ = [
26
32
  ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
27
33
  BashHostedHookEntry("observability/test_failure_recorder.py", ALL_BASH_ONLY_TOOL_NAMES),
28
34
  BashHostedHookEntry("advisory/pr_done_reminder.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
35
+ BashHostedHookEntry("advisory/msys_path_conversion_advisor.py", ALL_BASH_ONLY_TOOL_NAMES),
29
36
  )
30
37
 
38
+ EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
31
39
  POST_TOOL_USE_HOOK_EVENT_NAME: str = "PostToolUse"
32
40
  HOOK_SPECIFIC_OUTPUT_KEY: str = "hookSpecificOutput"
41
+ HOOK_EVENT_NAME_KEY: str = "hookEventName"
33
42
  ADDITIONAL_CONTEXT_KEY: str = "additionalContext"
34
43
  ADDITIONAL_CONTEXT_JOIN_SEPARATOR: str = "\n\n"
@@ -54,4 +54,9 @@ class BashHostedHookEntry:
54
54
  applicable_tool_names: frozenset[str]
55
55
 
56
56
 
57
- ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = ()
57
+ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
58
+ BashHostedHookEntry(
59
+ script_relative_path="blocking/msys_rev_path_rewriter.py",
60
+ applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
61
+ ),
62
+ )
@@ -16,6 +16,9 @@ __all__ = [
16
16
  "SEPARATOR_CELL_PATTERN",
17
17
  "BACKTICK_TOKEN_PATTERN",
18
18
  "ENV_VAR_NAME_PATTERN",
19
+ "ENV_VAR_HEADER_PATTERN",
20
+ "ALL_GENERIC_ENV_VAR_HEADERS",
21
+ "ENV_VAR_HEADING_PATTERN",
19
22
  "ALL_CODE_FILE_EXTENSIONS",
20
23
  "ALL_NOISE_DIRECTORY_NAMES",
21
24
  "GIT_DIRECTORY_NAME",
@@ -34,10 +37,13 @@ CODE_FENCE_PATTERN = re.compile(r"^\s*(```|~~~)")
34
37
  SEPARATOR_CELL_PATTERN = re.compile(r"^[:\-\s]+$")
35
38
  BACKTICK_TOKEN_PATTERN = re.compile(r"`([^`]+)`")
36
39
  ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,}$")
37
-
38
- ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
39
- {".py", ".mjs", ".js", ".ts", ".ps1", ".sh"}
40
+ ENV_VAR_HEADER_PATTERN = re.compile(
41
+ r"^(?:(?:environment|env)[ -]+)?(?:variables?|vars?)(?:[ -]+(?:name|key))?$", re.I
40
42
  )
43
+ ALL_GENERIC_ENV_VAR_HEADERS = frozenset({"name", "key"})
44
+ ENV_VAR_HEADING_PATTERN = re.compile(r"\b(?:environment|env)[ -]+(?:variables?|vars?)\b", re.I)
45
+
46
+ ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset({".py", ".mjs", ".js", ".ts", ".ps1", ".sh"})
41
47
  ALL_NOISE_DIRECTORY_NAMES: frozenset[str] = frozenset(
42
48
  {".git", "__pycache__", "node_modules", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
43
49
  )
@@ -61,4 +67,6 @@ DRIFT_ADDITIONAL_CONTEXT = (
61
67
  "its source never references the variable name, the row is stale. Drop the "
62
68
  "row, or point it at the variable the file actually reads."
63
69
  )
64
- DRIFT_SYSTEM_MESSAGE = "Blocked: env-var summary table attributes a variable to a code file that does not read it."
70
+ DRIFT_SYSTEM_MESSAGE = (
71
+ "Blocked: env-var summary table attributes a variable to a code file that does not read it."
72
+ )
@@ -4,15 +4,17 @@ import re
4
4
 
5
5
  HARDCODED_USER_PATH_PATTERN: re.Pattern[str] = re.compile(
6
6
  r"(?:"
7
- r"[A-Za-z]:[\\/]+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
8
- r"|\\{2,}(?:[^\\/\r\n]+[\\/]+)+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
9
- r"|(?<!:)//(?:[^\\/\r\n]+/)+(?i:users)/(?!(?i:Public|Shared|All Users)(?:/|$))[^/]+(?=/|$)"
10
- r"|(?i:file):///Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
11
- r"|(?i:file)://(?:[^/\r\n]+/)+(?i:Users)/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
12
- r"|(?i:file):///home/[^/]+(?=/|$)"
13
- r"|(?i:file)://(?:[^/\r\n]+/)+(?i:home)/[^/]+(?=/|$)"
14
- r"|(?<![A-Za-z0-9._:/\-\]])/Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
15
- r"|(?<![A-Za-z0-9._:/\-\]])/home/[^/]+(?=/|$))"
7
+ r"[A-Za-z]:[\\/]+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))(?:[^\\/\r\n\"`]+(?=[\\/])|[^\\/\r\n\"'`()]+(?=[\\/\r\n\"'`()]|$))"
8
+ r"|\\{2,}(?:[^\\/\r\n]+[\\/]+)+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))(?:[^\\/\r\n\"`]+(?=[\\/])|[^\\/\r\n\"'`()]+(?=[\\/\r\n\"'`()]|$))"
9
+ r"|(?<!:)//(?:[^\\/\r\n]+/)+(?i:users)/(?!(?i:Public|Shared|All Users)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
10
+ r"|(?i:file):///Users/(?!(?i:Shared|Public)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
11
+ r"|(?i:file)://(?:[^/\r\n]+/)+(?i:Users)/(?!(?i:Shared|Public)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
12
+ r"|(?i:file):///home/(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
13
+ r"|(?i:file)://(?:[^/\r\n]+/)+(?i:home)/(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
14
+ r"|(?<![A-Za-z0-9._:/\-\]])/Users/(?!(?i:Shared|Public)(?:/|$))(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$))"
15
+ r"|(?<![A-Za-z0-9._:/\-\]])/home/(?:[^/\r\n\"`]+(?=/)|[^/\r\n\"'`()]+(?=[/\r\n\"'`()]|$)))"
16
16
  )
17
17
  MAX_HARDCODED_USER_PATH_ISSUES: int = 25
18
- HARDCODED_USER_PATH_GUIDANCE: str = "use pathlib.Path.home() or os.path.expanduser('~') instead of a hardcoded user directory"
18
+ HARDCODED_USER_PATH_GUIDANCE: str = (
19
+ "use pathlib.Path.home() or os.path.expanduser('~') instead of a hardcoded user directory"
20
+ )
@@ -0,0 +1,48 @@
1
+ """Constants for the MSYS path-conversion context advisory.
2
+
3
+ The advisory is a PostToolUse observer on Bash. It never blocks. When a git
4
+ call fails because Git Bash rewrote a ``<rev>:<path>`` argument before git saw
5
+ it, the advisory adds one loud note naming the two exports that turn the
6
+ rewriting off.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "ALL_MANGLED_ARGUMENT_MARKERS",
13
+ "MANGLED_ARGUMENT_CLOSING_QUOTE",
14
+ "ALL_MSYS_MANGLING_CHARACTERS",
15
+ "ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES",
16
+ "ADVISORY_HEADER",
17
+ "MANGLED_ARGUMENT_LINE_TEMPLATE",
18
+ "MANGLING_EXPLANATION_LINE",
19
+ "FIX_LINE",
20
+ "SAME_COMMAND_LINE",
21
+ "ADVISORY_LINE_SEPARATOR",
22
+ ]
23
+
24
+ AMBIGUOUS_ARGUMENT_MARKER: str = "fatal: ambiguous argument '"
25
+ INVALID_OBJECT_NAME_MARKER: str = "fatal: invalid object name '"
26
+ ALL_MANGLED_ARGUMENT_MARKERS: tuple[str, ...] = (
27
+ AMBIGUOUS_ARGUMENT_MARKER,
28
+ INVALID_OBJECT_NAME_MARKER,
29
+ )
30
+ MANGLED_ARGUMENT_CLOSING_QUOTE: str = "'"
31
+ ALL_MSYS_MANGLING_CHARACTERS: frozenset[str] = frozenset({"\\", ";"})
32
+ MSYS_PATH_CONVERSION_VARIABLE_NAME: str = "MSYS_NO_PATHCONV"
33
+ MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME: str = "MSYS2_ARG_CONV_EXCL"
34
+ ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES: tuple[str, ...] = (
35
+ MSYS_PATH_CONVERSION_VARIABLE_NAME,
36
+ MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME,
37
+ )
38
+ MSYS_EXPORT_FIX_LINE: str = "export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'"
39
+
40
+ ADVISORY_HEADER: str = "=== MSYS PATH CONVERSION (context reminder, never a block) ==="
41
+ MANGLED_ARGUMENT_LINE_TEMPLATE: str = "Git saw: {argument}"
42
+ MANGLING_EXPLANATION_LINE: str = (
43
+ "Git Bash rewrote a <rev>:<path> argument before git saw it, turning the "
44
+ "colon into a semicolon and the slashes into backslashes."
45
+ )
46
+ FIX_LINE: str = "Fix: " + MSYS_EXPORT_FIX_LINE
47
+ SAME_COMMAND_LINE: str = "Put that export and the git command in the same command."
48
+ ADVISORY_LINE_SEPARATOR: str = "\n"
@@ -0,0 +1,37 @@
1
+ """Constants for the MSYS ``<rev>:<path>`` argument-exclusion rewriter.
2
+
3
+ Git Bash converts a git argument such as ``origin/main:.claude/settings.json``
4
+ before git sees it. ``MSYS2_ARG_CONV_EXCL`` takes a semicolon-separated list of
5
+ argument prefixes to leave alone, so naming one ``<rev>:`` prefix per detected
6
+ token keeps every other argument in the command converting as before.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from hooks_constants.msys_path_conversion_advisor_constants import (
12
+ MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME,
13
+ )
14
+
15
+ __all__ = [
16
+ "GIT_PROGRAM_NAME",
17
+ "REVISION_PATH_SEPARATOR",
18
+ "REVISION_PATH_SPLIT_COUNT",
19
+ "REVISION_SLASH_CHARACTER",
20
+ "ALL_CONVERTED_PATH_START_CHARACTERS",
21
+ "ALL_UNCONVERTED_PATH_START_PREFIXES",
22
+ "ALL_REVISION_FORBIDDEN_CHARACTERS",
23
+ "EXCLUSION_PREFIX_JOIN_SEPARATOR",
24
+ "EXCLUSION_EXPORT_TEMPLATE",
25
+ ]
26
+
27
+ GIT_PROGRAM_NAME: str = "git"
28
+ REVISION_PATH_SEPARATOR: str = ":"
29
+ REVISION_PATH_SPLIT_COUNT: int = 1
30
+ REVISION_SLASH_CHARACTER: str = "/"
31
+ ALL_CONVERTED_PATH_START_CHARACTERS: tuple[str, ...] = ("/", ".")
32
+ ALL_UNCONVERTED_PATH_START_PREFIXES: tuple[str, ...] = ("./", "../")
33
+ ALL_REVISION_FORBIDDEN_CHARACTERS: tuple[str, ...] = ("'", '"')
34
+ EXCLUSION_PREFIX_JOIN_SEPARATOR: str = ";"
35
+ EXCLUSION_EXPORT_TEMPLATE: str = (
36
+ "export " + MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME + "='{all_prefixes}'; "
37
+ )