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
|
@@ -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
|
|
129
|
-
"
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
)
|