claude-dev-env 4.1.0 → 4.3.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 (89) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/everything-search/SKILL.md +18 -3
  6. package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
  7. package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
  8. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
  9. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
  10. package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
  11. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  12. package/.agents/skills/pull-request/SKILL.md +218 -0
  13. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  14. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  15. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  16. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  17. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  18. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  19. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  20. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  21. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  22. package/.agents/skills/test-runner/SKILL.md +69 -0
  23. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  24. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  25. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  26. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  27. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  28. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  29. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  30. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  31. package/bin/ever-shipped-skills.mjs +2 -0
  32. package/bin/ever-shipped-skills.test.mjs +8 -0
  33. package/bin/install.mjs +9 -1
  34. package/bin/install.prune.test.mjs +149 -0
  35. package/bin/install.test.mjs +24 -7
  36. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  37. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  40. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  41. package/hooks/gh_post_body_texts.py +195 -0
  42. package/hooks/hooks.json +0 -15
  43. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
  45. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  46. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  47. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
  48. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  49. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  50. package/hooks/observability/test_failure_recorder.py +1 -1
  51. package/hooks/pyproject.toml +1 -1
  52. package/hooks/test_gh_post_body_texts.py +79 -0
  53. package/package.json +1 -1
  54. package/rules/durable-post-artifacts.md +31 -20
  55. package/rules/filesystem-search.md +5 -5
  56. package/rules/gh-cli-conventions.md +23 -13
  57. package/rules/git-workflow.md +21 -8
  58. package/scripts/policy_lint/adapter_configuration.py +53 -12
  59. package/scripts/repository_checks/config/constants.py +0 -5
  60. package/scripts/tests/test_adapter_configuration.py +54 -0
  61. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  62. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  63. package/hooks/blocking/es_exe_path_rewriter.py +0 -159
  64. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  65. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  66. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  67. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  68. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  69. package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
  70. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  71. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  72. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  73. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  74. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  75. package/hooks/blocking/test_preflight_check.py +0 -343
  76. package/hooks/blocking/test_test_preflight_check.py +0 -80
  77. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  78. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  79. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  80. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  81. package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
  82. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  83. package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
  84. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  85. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  86. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  87. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  88. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  89. package/hooks/test__gh_pr_author_swap_utils.py +0 -333
@@ -42,6 +42,14 @@ SYNTHETIC_REAL_EMAIL = "owner.fixture@acme-corp.example.io"
42
42
  SYNTHETIC_HOME_PATH = r"C:\Users\fixture_commit_user\secret.txt"
43
43
  SYNTHETIC_PRIVATE_IP = "10.44.12.9"
44
44
  SYNTHETIC_SAFE_BODY = "All checks pass. Contact user@example.com for docs."
45
+ HASH_MARKER = "#"
46
+
47
+
48
+ def test_post_body_extraction_has_a_neutral_module_owner() -> None:
49
+ assert (
50
+ blocker_module.extract_gh_post_body_texts_for_privacy_gate.__module__
51
+ == "gh_post_body_texts"
52
+ )
45
53
 
46
54
 
47
55
  def _run_hook(payload: dict[str, object]) -> tuple[int, str]:
@@ -370,10 +378,7 @@ def test_dispatcher_roster_hosts_pii_blocker() -> None:
370
378
  if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
371
379
  ]
372
380
  assert len(matching_bash_entries) == 1
373
- assert (
374
- matching_bash_entries[0].applicable_tool_names
375
- == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
376
- )
381
+ assert matching_bash_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
377
382
 
378
383
 
379
384
  def test_is_git_commit_shell_command_covers_windows_and_flag_forms() -> None:
@@ -531,9 +536,7 @@ def test_is_git_commit_shell_command_detects_glued_and_newline_separators() -> N
531
536
  assert is_git_commit_shell_command("cd repo&&git commit -m x")
532
537
  assert is_git_commit_shell_command("git add notes.md\ngit commit -m update")
533
538
  assert is_git_commit_shell_command("git status\ngit commit -m x")
534
- assert not is_git_commit_shell_command(
535
- "gh pr comment 1 --body 'notes\ngit commit -m x'"
536
- )
539
+ assert not is_git_commit_shell_command("gh pr comment 1 --body 'notes\ngit commit -m x'")
537
540
 
538
541
 
539
542
  def test_is_git_commit_shell_command_detects_backgrounded_commit() -> None:
@@ -681,11 +684,9 @@ def test_argument_bearing_wrapper_commit_scans_staged_pii(tmp_path: Path) -> Non
681
684
 
682
685
 
683
686
  def test_is_git_commit_shell_command_detects_commit_after_hash_bearing_token() -> None:
684
- assert is_git_commit_shell_command(
685
- "curl https://example.com/page#section && git commit -am x"
686
- )
687
+ command = f"curl https://example.com/page{HASH_MARKER}section && git commit -am x"
688
+ assert is_git_commit_shell_command(command)
687
689
  assert is_git_commit_shell_command("echo done#note && git commit -m x")
688
- assert is_git_commit_shell_command('git commit -m "fix #123"')
689
690
 
690
691
 
691
692
  def test_hash_bearing_token_before_commit_scans_staged_pii(tmp_path: Path) -> None:
@@ -721,21 +722,13 @@ def test_is_git_commit_shell_command_detects_unquoted_powershell_command() -> No
721
722
 
722
723
 
723
724
  def test_is_git_commit_shell_command_detects_powershell_preflags_before_command() -> None:
724
- assert is_git_commit_shell_command(
725
- 'pwsh -ExecutionPolicy Bypass -Command "git commit -m x"'
726
- )
727
- assert is_git_commit_shell_command(
728
- 'pwsh -NonInteractive -Command "git commit -m x"'
729
- )
725
+ assert is_git_commit_shell_command('pwsh -ExecutionPolicy Bypass -Command "git commit -m x"')
726
+ assert is_git_commit_shell_command('pwsh -NonInteractive -Command "git commit -m x"')
730
727
  assert is_git_commit_shell_command(
731
728
  'powershell -ExecutionPolicy Bypass -Command "git commit -m x"'
732
729
  )
733
- assert is_git_commit_shell_command(
734
- 'pwsh -NoProfile -NonInteractive -Command "git commit -m x"'
735
- )
736
- assert not is_git_commit_shell_command(
737
- 'pwsh -NonInteractive -Command "git status"'
738
- )
730
+ assert is_git_commit_shell_command('pwsh -NoProfile -NonInteractive -Command "git commit -m x"')
731
+ assert not is_git_commit_shell_command('pwsh -NonInteractive -Command "git status"')
739
732
 
740
733
 
741
734
  def test_is_git_commit_shell_command_detects_subshell_grouped_commit() -> None:
@@ -835,9 +828,7 @@ def test_is_git_commit_shell_command_detects_commit_after_apostrophe_line() -> N
835
828
 
836
829
 
837
830
  def test_is_git_commit_shell_command_ignores_commit_inside_quoted_body() -> None:
838
- assert not is_git_commit_shell_command(
839
- "gh pr comment 1 --body 'notes\ngit commit -m x'"
840
- )
831
+ assert not is_git_commit_shell_command("gh pr comment 1 --body 'notes\ngit commit -m x'")
841
832
 
842
833
 
843
834
  def test_apostrophe_line_before_commit_scans_staged_pii(tmp_path: Path) -> None:
@@ -873,17 +864,9 @@ def test_powershell_backtick_continuation_commit_scans_staged_pii(
873
864
 
874
865
 
875
866
  def test_extract_git_commit_working_directory_resolves_dash_c_forms() -> None:
876
- assert (
877
- extract_git_commit_working_directory(r"git.exe -C /repoA commit -m x")
878
- == "/repoA"
879
- )
880
- assert (
881
- extract_git_commit_working_directory(r"git -C /repoA commit -m x") == "/repoA"
882
- )
883
- assert (
884
- extract_git_commit_working_directory(r'git -C "C:/repo" commit -m x')
885
- == "C:/repo"
886
- )
867
+ assert extract_git_commit_working_directory(r"git.exe -C /repoA commit -m x") == "/repoA"
868
+ assert extract_git_commit_working_directory(r"git -C /repoA commit -m x") == "/repoA"
869
+ assert extract_git_commit_working_directory(r'git -C "C:/repo" commit -m x') == "C:/repo"
887
870
  assert extract_git_commit_working_directory("git commit -m x") is None
888
871
 
889
872
 
@@ -921,9 +904,7 @@ def test_exempt_repository_commit_skips_staged_pii_scan(
921
904
  ) -> None:
922
905
  repository_root = tmp_path / "repo"
923
906
  _init_repo_with_staged_email(repository_root)
924
- _add_repository_origin(
925
- repository_root, "https://github.com/ExemptOwner/exempt-repo.git"
926
- )
907
+ _add_repository_origin(repository_root, "https://github.com/ExemptOwner/exempt-repo.git")
927
908
  monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
928
909
  deny_reason = evaluate_bash_command(
929
910
  "git commit -m test",
@@ -937,9 +918,7 @@ def test_exempt_repository_matches_ssh_origin_url(
937
918
  ) -> None:
938
919
  repository_root = tmp_path / "repo"
939
920
  _init_repo_with_staged_email(repository_root)
940
- _add_repository_origin(
941
- repository_root, "git@github.com:ExemptOwner/exempt-repo.git"
942
- )
921
+ _add_repository_origin(repository_root, "git@github.com:ExemptOwner/exempt-repo.git")
943
922
  monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "exemptowner/EXEMPT-REPO")
944
923
  deny_reason = evaluate_bash_command(
945
924
  "git commit -m test",
@@ -953,9 +932,7 @@ def test_non_exempt_repository_commit_still_scans_staged_pii(
953
932
  ) -> None:
954
933
  repository_root = tmp_path / "repo"
955
934
  _init_repo_with_staged_email(repository_root)
956
- _add_repository_origin(
957
- repository_root, "https://github.com/OtherOwner/other-repo.git"
958
- )
935
+ _add_repository_origin(repository_root, "https://github.com/OtherOwner/other-repo.git")
959
936
  monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
960
937
  deny_reason = evaluate_bash_command(
961
938
  "git commit -m test",
@@ -981,45 +958,35 @@ def test_repository_without_origin_is_not_exempt(
981
958
 
982
959
  def test_owner_repo_slug_from_https_origin_with_git_suffix() -> None:
983
960
  assert (
984
- blocker_module._owner_repo_slug_from_origin_url(
985
- "https://github.com/Owner/repo.git"
986
- )
961
+ blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo.git")
987
962
  == "owner/repo"
988
963
  )
989
964
 
990
965
 
991
966
  def test_owner_repo_slug_from_https_origin_without_git_suffix() -> None:
992
967
  assert (
993
- blocker_module._owner_repo_slug_from_origin_url(
994
- "https://github.com/Owner/repo"
995
- )
968
+ blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo")
996
969
  == "owner/repo"
997
970
  )
998
971
 
999
972
 
1000
973
  def test_owner_repo_slug_from_scp_ssh_origin() -> None:
1001
974
  assert (
1002
- blocker_module._owner_repo_slug_from_origin_url(
1003
- "git@github.com:Owner/repo.git"
1004
- )
975
+ blocker_module._owner_repo_slug_from_origin_url("git@github.com:Owner/repo.git")
1005
976
  == "owner/repo"
1006
977
  )
1007
978
 
1008
979
 
1009
980
  def test_owner_repo_slug_from_ssh_scheme_origin() -> None:
1010
981
  assert (
1011
- blocker_module._owner_repo_slug_from_origin_url(
1012
- "ssh://git@github.com/Owner/repo.git"
1013
- )
982
+ blocker_module._owner_repo_slug_from_origin_url("ssh://git@github.com/Owner/repo.git")
1014
983
  == "owner/repo"
1015
984
  )
1016
985
 
1017
986
 
1018
987
  def test_owner_repo_slug_from_trailing_slash_git_suffix() -> None:
1019
988
  assert (
1020
- blocker_module._owner_repo_slug_from_origin_url(
1021
- "https://github.com/Owner/repo.git/"
1022
- )
989
+ blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo.git/")
1023
990
  == "owner/repo"
1024
991
  )
1025
992
 
@@ -1035,9 +1002,7 @@ def test_owner_repo_slug_rejects_spoofed_evil_host() -> None:
1035
1002
 
1036
1003
  def test_owner_repo_slug_rejects_notgithub_com_host() -> None:
1037
1004
  assert (
1038
- blocker_module._owner_repo_slug_from_origin_url(
1039
- "https://notgithub.com/Owner/repo.git"
1040
- )
1005
+ blocker_module._owner_repo_slug_from_origin_url("https://notgithub.com/Owner/repo.git")
1041
1006
  is None
1042
1007
  )
1043
1008
 
@@ -1071,28 +1036,21 @@ def test_owner_repo_slug_rejects_invalid_ssh_port_authority() -> None:
1071
1036
 
1072
1037
  def test_owner_repo_slug_from_https_origin_with_valid_port() -> None:
1073
1038
  assert (
1074
- blocker_module._owner_repo_slug_from_origin_url(
1075
- "https://github.com:443/Owner/repo.git"
1076
- )
1039
+ blocker_module._owner_repo_slug_from_origin_url("https://github.com:443/Owner/repo.git")
1077
1040
  == "owner/repo"
1078
1041
  )
1079
1042
 
1080
1043
 
1081
1044
  def test_owner_repo_slug_from_ssh_origin_with_valid_port() -> None:
1082
1045
  assert (
1083
- blocker_module._owner_repo_slug_from_origin_url(
1084
- "ssh://git@github.com:22/Owner/repo.git"
1085
- )
1046
+ blocker_module._owner_repo_slug_from_origin_url("ssh://git@github.com:22/Owner/repo.git")
1086
1047
  == "owner/repo"
1087
1048
  )
1088
1049
 
1089
1050
 
1090
1051
  def test_owner_repo_slug_rejects_path_shaped_origin() -> None:
1091
1052
  assert (
1092
- blocker_module._owner_repo_slug_from_origin_url(
1093
- "C:/repos/ExemptOwner/exempt-repo"
1094
- )
1095
- is None
1053
+ blocker_module._owner_repo_slug_from_origin_url("C:/repos/ExemptOwner/exempt-repo") is None
1096
1054
  )
1097
1055
 
1098
1056
 
@@ -1101,9 +1059,7 @@ def test_spoofed_origin_host_still_denies_staged_email(
1101
1059
  ) -> None:
1102
1060
  repository_root = tmp_path / "repo"
1103
1061
  _init_repo_with_staged_email(repository_root)
1104
- _add_repository_origin(
1105
- repository_root, "https://evil.test/ExemptOwner/exempt-repo.git"
1106
- )
1062
+ _add_repository_origin(repository_root, "https://evil.test/ExemptOwner/exempt-repo.git")
1107
1063
  monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1108
1064
  deny_reason = evaluate_bash_command(
1109
1065
  "git commit -m test",
@@ -1151,9 +1107,7 @@ def test_trailing_slash_git_suffix_origin_still_exempts(
1151
1107
  ) -> None:
1152
1108
  repository_root = tmp_path / "repo"
1153
1109
  _init_repo_with_staged_email(repository_root)
1154
- _add_repository_origin(
1155
- repository_root, "https://github.com/ExemptOwner/exempt-repo.git/"
1156
- )
1110
+ _add_repository_origin(repository_root, "https://github.com/ExemptOwner/exempt-repo.git/")
1157
1111
  monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1158
1112
  deny_reason = evaluate_bash_command(
1159
1113
  "git commit -m test",
@@ -1162,14 +1116,10 @@ def test_trailing_slash_git_suffix_origin_still_exempts(
1162
1116
  assert deny_reason is None
1163
1117
 
1164
1118
 
1165
- def test_ssh_scheme_origin_still_exempts(
1166
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1167
- ) -> None:
1119
+ def test_ssh_scheme_origin_still_exempts(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
1168
1120
  repository_root = tmp_path / "repo"
1169
1121
  _init_repo_with_staged_email(repository_root)
1170
- _add_repository_origin(
1171
- repository_root, "ssh://git@github.com/ExemptOwner/exempt-repo.git"
1172
- )
1122
+ _add_repository_origin(repository_root, "ssh://git@github.com/ExemptOwner/exempt-repo.git")
1173
1123
  monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1174
1124
  deny_reason = evaluate_bash_command(
1175
1125
  "git commit -m test",
@@ -1197,9 +1147,7 @@ def test_post_body_still_scans_when_repository_is_exempt(
1197
1147
  monkeypatch: pytest.MonkeyPatch,
1198
1148
  ) -> None:
1199
1149
  monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1200
- deny_reason = evaluate_post_body_texts(
1201
- [f"Reach me at {SYNTHETIC_REAL_EMAIL}"]
1202
- )
1150
+ deny_reason = evaluate_post_body_texts([f"Reach me at {SYNTHETIC_REAL_EMAIL}"])
1203
1151
  assert deny_reason is not None
1204
1152
  assert "email" in deny_reason
1205
1153
 
@@ -0,0 +1,195 @@
1
+ """Extract durable GitHub post body text for privacy checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from pathlib import Path
7
+
8
+ from blocking._gh_body_arg_utils import (
9
+ all_body_flags,
10
+ body_file_flag,
11
+ body_file_short_flag,
12
+ count_extra_tokens_to_skip_for_split_quoted_value,
13
+ get_logical_first_line,
14
+ is_unresolvable_shell_value,
15
+ match_body_file_equals_prefix,
16
+ match_body_flag_equals_prefix,
17
+ strip_surrounding_quotes,
18
+ )
19
+ from hooks_constants.gh_post_body_texts_constants import (
20
+ ALL_GH_POST_SUBCOMMANDS,
21
+ ALL_MCP_BODY_PARAM_NAMES,
22
+ BODY_FILE_ENCODING,
23
+ BODY_FLAG_WITH_VALUE_STEP,
24
+ GH_COMMAND_NAME,
25
+ MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT,
26
+ TOKEN_JOIN_SEPARATOR,
27
+ )
28
+
29
+
30
+ def _is_environment_assignment(token: str) -> bool:
31
+ equals_index = token.find("=")
32
+ if equals_index <= 0:
33
+ return False
34
+ name_part = token[:equals_index]
35
+ if not (name_part[0].isalpha() or name_part[0] == "_"):
36
+ return False
37
+ return all(each_character.isalnum() or each_character == "_" for each_character in name_part)
38
+
39
+
40
+ def _tokens_name_gh_post_command(all_command_tokens: list[str]) -> bool:
41
+ command_index = 0
42
+ while command_index < len(all_command_tokens) and _is_environment_assignment(
43
+ all_command_tokens[command_index]
44
+ ):
45
+ command_index += 1
46
+ if command_index >= len(all_command_tokens):
47
+ return False
48
+ if all_command_tokens[command_index] != GH_COMMAND_NAME:
49
+ return False
50
+ remaining_tokens = all_command_tokens[command_index + 1 :]
51
+ if len(remaining_tokens) < MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT:
52
+ return False
53
+ post_noun, post_verb = remaining_tokens[:MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT]
54
+ return post_verb in ALL_GH_POST_SUBCOMMANDS.get(post_noun, frozenset())
55
+
56
+
57
+ def _reassemble_split_quoted_value(
58
+ value_token: str,
59
+ all_following_tokens: list[str],
60
+ ) -> tuple[str, int]:
61
+ extra_token_count = count_extra_tokens_to_skip_for_split_quoted_value(
62
+ all_following_tokens, value_token
63
+ )
64
+ if not extra_token_count:
65
+ return strip_surrounding_quotes(value_token), 0
66
+ joined_value = (
67
+ value_token
68
+ + TOKEN_JOIN_SEPARATOR
69
+ + TOKEN_JOIN_SEPARATOR.join(all_following_tokens[:extra_token_count])
70
+ )
71
+ return strip_surrounding_quotes(joined_value), extra_token_count
72
+
73
+
74
+ def _extract_flag_value_at(
75
+ all_command_tokens: list[str],
76
+ token_index: int,
77
+ ) -> tuple[bool, str, int] | None:
78
+ current_token = all_command_tokens[token_index]
79
+ following_tokens = all_command_tokens[token_index + 1 :]
80
+ body_prefix = match_body_flag_equals_prefix(current_token)
81
+ if body_prefix is not None:
82
+ value, extra_count = _reassemble_split_quoted_value(
83
+ current_token[len(body_prefix) :], following_tokens
84
+ )
85
+ return False, value, token_index + 1 + extra_count
86
+ body_file_prefix = match_body_file_equals_prefix(current_token)
87
+ if body_file_prefix is not None:
88
+ value, extra_count = _reassemble_split_quoted_value(
89
+ current_token[len(body_file_prefix) :], following_tokens
90
+ )
91
+ return True, value, token_index + 1 + extra_count
92
+ if current_token in all_body_flags and following_tokens:
93
+ value, extra_count = _reassemble_split_quoted_value(
94
+ following_tokens[0], following_tokens[1:]
95
+ )
96
+ return False, value, token_index + BODY_FLAG_WITH_VALUE_STEP + extra_count
97
+ if current_token in (body_file_flag, body_file_short_flag) and following_tokens:
98
+ value, extra_count = _reassemble_split_quoted_value(
99
+ following_tokens[0], following_tokens[1:]
100
+ )
101
+ return True, value, token_index + BODY_FLAG_WITH_VALUE_STEP + extra_count
102
+ return None
103
+
104
+
105
+ def _collect_body_flag_values(
106
+ all_command_tokens: list[str],
107
+ ) -> tuple[list[str], list[str]]:
108
+ all_inline_bodies: list[str] = []
109
+ all_body_file_paths: list[str] = []
110
+ token_index = 0
111
+ while token_index < len(all_command_tokens):
112
+ extraction = _extract_flag_value_at(all_command_tokens, token_index)
113
+ if extraction is None:
114
+ token_index += 1
115
+ continue
116
+ is_body_file, value, token_index = extraction
117
+ if is_body_file:
118
+ all_body_file_paths.append(value)
119
+ else:
120
+ all_inline_bodies.append(value)
121
+ return all_inline_bodies, all_body_file_paths
122
+
123
+
124
+ def _read_body_file(
125
+ body_file_path: str,
126
+ working_directory: str | None,
127
+ ) -> str | None:
128
+ if is_unresolvable_shell_value(body_file_path):
129
+ return None
130
+ resolved_path = Path(body_file_path)
131
+ if not resolved_path.is_absolute() and working_directory:
132
+ resolved_path = Path(working_directory) / body_file_path
133
+ try:
134
+ return resolved_path.read_text(encoding=BODY_FILE_ENCODING)
135
+ except (OSError, UnicodeError):
136
+ return None
137
+
138
+
139
+ def _body_texts_from_tokens(
140
+ all_command_tokens: list[str],
141
+ working_directory: str | None,
142
+ ) -> tuple[list[str], str | None]:
143
+ if not _tokens_name_gh_post_command(all_command_tokens):
144
+ return [], None
145
+ all_body_texts, all_body_file_paths = _collect_body_flag_values(all_command_tokens)
146
+ for each_path in all_body_file_paths:
147
+ file_text = _read_body_file(each_path, working_directory)
148
+ if file_text is None:
149
+ return [], (
150
+ "BLOCKED [pii_prevention_blocker]: durable post uses --body-file "
151
+ f"but '{each_path}' could not be read for PII scanning. Use an "
152
+ "absolute path, ensure the file exists, or pass --body text."
153
+ )
154
+ all_body_texts.append(file_text)
155
+ return all_body_texts, None
156
+
157
+
158
+ def extract_gh_post_body_texts_for_privacy_gate(
159
+ command: str,
160
+ working_directory: str | None = None,
161
+ ) -> tuple[list[str], str | None]:
162
+ """Extract GitHub post text and reject unreadable declared body files.
163
+
164
+ Args:
165
+ command: Raw GitHub CLI command.
166
+ working_directory: Base directory for relative body-file paths.
167
+
168
+ Returns:
169
+ Body texts and no error, or no texts and a rejection message without body text.
170
+ """
171
+ logical_line = get_logical_first_line(command)
172
+ if not logical_line:
173
+ return [], None
174
+ try:
175
+ all_command_tokens = shlex.split(logical_line, posix=False)
176
+ except ValueError:
177
+ return [], None
178
+ return _body_texts_from_tokens(all_command_tokens, working_directory)
179
+
180
+
181
+ def extract_mcp_body_texts(all_tool_input: dict[str, object]) -> list[str]:
182
+ """Extract body and comment strings from a GitHub tool request.
183
+
184
+ Args:
185
+ all_tool_input: GitHub tool input mapping.
186
+
187
+ Returns:
188
+ Nonempty body and comment values.
189
+ """
190
+ all_body_texts: list[str] = []
191
+ for each_name in ALL_MCP_BODY_PARAM_NAMES:
192
+ candidate_text = all_tool_input.get(each_name)
193
+ if isinstance(candidate_text, str) and candidate_text:
194
+ all_body_texts.append(candidate_text)
195
+ return all_body_texts
package/hooks/hooks.json CHANGED
@@ -45,11 +45,6 @@
45
45
  {
46
46
  "matcher": "mcp__plugin_github_github__.*",
47
47
  "hooks": [
48
- {
49
- "type": "command",
50
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/volatile_path_in_post_blocker.py",
51
- "timeout": 10
52
- },
53
48
  {
54
49
  "type": "command",
55
50
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pii_prevention_blocker.py",
@@ -149,11 +144,6 @@
149
144
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py",
150
145
  "timeout": 10
151
146
  },
152
- {
153
- "type": "command",
154
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/gh_pr_author_session_cleanup.py",
155
- "timeout": 30
156
- },
157
147
  {
158
148
  "type": "command",
159
149
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
@@ -251,11 +241,6 @@
251
241
  "type": "command",
252
242
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/investigation_tracker_reset.py",
253
243
  "timeout": 30
254
- },
255
- {
256
- "type": "command",
257
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/pr_description_writer_spawn_tracker.py",
258
- "timeout": 30
259
244
  }
260
245
  ]
261
246
  },
@@ -17,6 +17,5 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
17
17
  __all__ = ["ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES"]
18
18
 
19
19
  ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
20
- BashHostedHookEntry("blocking/gh_pr_author_restore.py", ALL_BASH_ONLY_TOOL_NAMES),
21
20
  BashHostedHookEntry("observability/test_failure_recorder.py", ALL_BASH_ONLY_TOOL_NAMES),
22
21
  )
@@ -35,7 +35,9 @@ BASH_TOOL_NAME = "Bash"
35
35
  POWERSHELL_TOOL_NAME = "PowerShell"
36
36
 
37
37
  ALL_BASH_ONLY_TOOL_NAMES: frozenset[str] = frozenset({BASH_TOOL_NAME})
38
- ALL_BASH_AND_POWERSHELL_TOOL_NAMES: frozenset[str] = frozenset({BASH_TOOL_NAME, POWERSHELL_TOOL_NAME})
38
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES: frozenset[str] = frozenset(
39
+ {BASH_TOOL_NAME, POWERSHELL_TOOL_NAME}
40
+ )
39
41
 
40
42
 
41
43
  @dataclass(frozen=True)
@@ -53,30 +55,17 @@ class BashHostedHookEntry:
53
55
 
54
56
 
55
57
  ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
56
- BashHostedHookEntry("blocking/es_exe_path_rewriter.py", ALL_BASH_ONLY_TOOL_NAMES),
57
58
  BashHostedHookEntry("blocking/destructive_command_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
58
- BashHostedHookEntry("blocking/gh_body_arg_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
59
59
  BashHostedHookEntry("blocking/shell_substitution_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
60
60
  BashHostedHookEntry("blocking/piped_pytest_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
61
61
  BashHostedHookEntry(
62
62
  "blocking/cursor_cli_python_misfire_blocker.py",
63
63
  ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
64
64
  ),
65
- BashHostedHookEntry(
66
- "blocking/unscoped_search_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
67
- ),
65
+ BashHostedHookEntry("blocking/unscoped_search_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
68
66
  BashHostedHookEntry("blocking/nas_ssh_binary_enforcer.py", ALL_BASH_ONLY_TOOL_NAMES),
69
- BashHostedHookEntry("blocking/volatile_path_in_post_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
70
- BashHostedHookEntry(
71
- "blocking/pii_prevention_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
72
- ),
73
- BashHostedHookEntry("blocking/conventional_pr_title_gate.py", ALL_BASH_ONLY_TOOL_NAMES),
67
+ BashHostedHookEntry("blocking/pii_prevention_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
74
68
  BashHostedHookEntry("blocking/block_main_commit.py", ALL_BASH_ONLY_TOOL_NAMES),
75
69
  BashHostedHookEntry("blocking/session_edit_stage_gate.py", ALL_BASH_ONLY_TOOL_NAMES),
76
- BashHostedHookEntry("blocking/test_preflight_check.py", ALL_BASH_ONLY_TOOL_NAMES),
77
70
  BashHostedHookEntry("blocking/windows_rmtree_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
78
- BashHostedHookEntry("blocking/gh_pr_author_enforcer.py", ALL_BASH_ONLY_TOOL_NAMES),
79
- BashHostedHookEntry(
80
- "blocking/pr_description_writer_gate.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
81
- ),
82
71
  )
@@ -0,0 +1,12 @@
1
+ """Configuration constants for GitHub post body extraction."""
2
+
3
+ ALL_GH_POST_SUBCOMMANDS: dict[str, frozenset[str]] = {
4
+ "issue": frozenset({"comment", "create", "edit"}),
5
+ "pr": frozenset({"comment", "create", "edit", "review"}),
6
+ }
7
+ ALL_MCP_BODY_PARAM_NAMES: tuple[str, ...] = ("body", "comment")
8
+ BODY_FILE_ENCODING = "utf-8"
9
+ BODY_FLAG_WITH_VALUE_STEP = 2
10
+ GH_COMMAND_NAME = "gh"
11
+ MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT = 2
12
+ TOKEN_JOIN_SEPARATOR = " "
@@ -6,13 +6,6 @@ from hooks_constants.bash_post_call_dispatcher_constants import (
6
6
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
7
7
 
8
8
 
9
- def test_roster_names_the_gh_pr_author_restore_hook() -> None:
10
- all_script_paths = [
11
- each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
12
- ]
13
- assert "blocking/gh_pr_author_restore.py" in all_script_paths
14
-
15
-
16
9
  def test_roster_names_the_test_failure_recorder_hook() -> None:
17
10
  all_script_paths = [
18
11
  each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
@@ -7,45 +7,34 @@ registration order the standalone chain used.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import sys
11
10
  from pathlib import Path
11
+ from runpy import run_path
12
12
 
13
- _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
14
- if _HOOKS_DIR not in sys.path:
15
- sys.path.insert(0, _HOOKS_DIR)
16
-
17
- from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
18
- ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
19
- ALL_BASH_HOSTED_HOOK_ENTRIES,
20
- BASH_TOOL_NAME,
21
- POWERSHELL_TOOL_NAME,
13
+ ALL_CONSTANT_BINDINGS = run_path(
14
+ str(Path(__file__).with_name("bash_pre_tool_use_dispatcher_constants.py"))
22
15
  )
16
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSHELL_TOOL_NAMES"]
17
+ ALL_BASH_HOSTED_HOOK_ENTRIES = ALL_CONSTANT_BINDINGS["ALL_BASH_HOSTED_HOOK_ENTRIES"]
18
+ BASH_TOOL_NAME = ALL_CONSTANT_BINDINGS["BASH_TOOL_NAME"]
19
+ POWERSHELL_TOOL_NAME = ALL_CONSTANT_BINDINGS["POWERSHELL_TOOL_NAME"]
23
20
 
24
21
  _EXPECTED_BASH_ORDER = (
25
- "blocking/es_exe_path_rewriter.py",
26
22
  "blocking/destructive_command_blocker.py",
27
- "blocking/gh_body_arg_blocker.py",
28
23
  "blocking/shell_substitution_blocker.py",
29
24
  "blocking/piped_pytest_blocker.py",
30
25
  "blocking/cursor_cli_python_misfire_blocker.py",
31
26
  "blocking/unscoped_search_blocker.py",
32
27
  "blocking/nas_ssh_binary_enforcer.py",
33
- "blocking/volatile_path_in_post_blocker.py",
34
28
  "blocking/pii_prevention_blocker.py",
35
- "blocking/conventional_pr_title_gate.py",
36
29
  "blocking/block_main_commit.py",
37
30
  "blocking/session_edit_stage_gate.py",
38
- "blocking/test_preflight_check.py",
39
31
  "blocking/windows_rmtree_blocker.py",
40
- "blocking/gh_pr_author_enforcer.py",
41
- "blocking/pr_description_writer_gate.py",
42
32
  )
43
33
 
44
34
  _POWERSHELL_APPLICABLE = (
45
35
  "blocking/cursor_cli_python_misfire_blocker.py",
46
36
  "blocking/unscoped_search_blocker.py",
47
37
  "blocking/pii_prevention_blocker.py",
48
- "blocking/pr_description_writer_gate.py",
49
38
  )
50
39
 
51
40
 
@@ -80,8 +69,6 @@ def test_powershell_hooks_carry_the_shared_tool_set() -> None:
80
69
  assert each_entry.applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
81
70
 
82
71
 
83
-
84
-
85
72
  def test_every_roster_path_points_at_an_existing_hook() -> None:
86
73
  """Each roster path names a hook script present on disk, catching a typo."""
87
74
  hooks_root = Path(__file__).resolve().parent.parent
@@ -6,9 +6,7 @@ tokens that keep the recognizer to one unchained command, the pytest program
6
6
  and Python interpreter basenames, and the flag-token marker the path
7
7
  extractor uses to skip an option rather than an existing test file argument.
8
8
 
9
- ``BASH_TOOL_NAME`` lives in ``gh_pr_author_swap_constants`` already; this
10
- module reuses that name rather than a second literal. The pytest-program and
11
- interpreter recognition here is deliberately its own small, stdlib-only
9
+ The pytest-program and interpreter recognition here is deliberately its own small, stdlib-only
12
10
  check rather than a reuse of ``piped_pytest_blocker_constants`` /
13
11
  ``pytest_invocation``: those solve the harder pipe-and-wrapper problem and
14
12
  pull in a much larger transitive import for every Bash call regardless of
@@ -19,6 +17,8 @@ from __future__ import annotations
19
17
 
20
18
  import re
21
19
 
20
+ BASH_TOOL_NAME = "Bash"
21
+
22
22
  ALL_PYTEST_MENTION_SUBSTRINGS: tuple[str, ...] = ("pytest", "py.test")
23
23
  EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
24
24
  OPTION_TOKEN_PREFIX: str = "-"