claude-dev-env 1.92.1 → 1.93.1

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 (215) hide show
  1. package/CLAUDE.md +6 -2
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +260 -14
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +292 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +365 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  79. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  81. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  82. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  84. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  85. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  86. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  87. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  88. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  89. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  90. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  91. package/hooks/blocking/test_pii_scanner.py +190 -0
  92. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  93. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  94. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  95. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  96. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  97. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  98. package/hooks/diagnostic/migrations/README.md +25 -26
  99. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  100. package/hooks/hooks.json +10 -120
  101. package/hooks/hooks_constants/CLAUDE.md +12 -3
  102. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  103. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  104. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  105. package/hooks/hooks_constants/local_identity.py +182 -0
  106. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  107. package/hooks/hooks_constants/pii_prevention_constants.py +295 -0
  108. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  109. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  110. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  111. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  112. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  113. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  114. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  115. package/hooks/hooks_constants/test_local_identity.py +88 -0
  116. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  117. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  118. package/hooks/validators/README.md +5 -6
  119. package/hooks/validators/__init__.py +2 -2
  120. package/hooks/validators/python_style_checks.py +298 -243
  121. package/hooks/validators/run_all_validators.py +6 -0
  122. package/hooks/validators/test_python_style_checks.py +278 -163
  123. package/package.json +2 -2
  124. package/rules/CLAUDE.md +2 -1
  125. package/rules/bdd.md +1 -1
  126. package/rules/nas-ssh-invocation.md +6 -4
  127. package/rules/no-justification-noise.md +61 -0
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +74 -78
  179. package/skills/orchestrator-refresh/SKILL.md +24 -12
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +56 -0
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
@@ -0,0 +1,41 @@
1
+ """Windows path detection tests for the PII prevention commit gate.
2
+
3
+ Guards the unquoted absolute Windows git path shape on the PowerShell surface::
4
+
5
+ is_git_commit_shell_command(r"C:\\tools\\git.exe commit")
6
+ ok: True -- the git.exe basename survives tokenization
7
+ flag: False -- backslash escaping mangles the basename token
8
+
9
+ An unquoted absolute path to the git binary must still be recognized as a
10
+ commit so a staged blob carrying PII cannot slip past the gate.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
20
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
21
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
22
+
23
+ _blocker_module = importlib.import_module("pii_prevention_blocker")
24
+
25
+
26
+ def test_unquoted_windows_git_exe_path_is_detected_as_commit() -> None:
27
+ assert _blocker_module.is_git_commit_shell_command(r"C:\tools\git.exe commit -m x")
28
+
29
+
30
+ def test_unquoted_windows_git_path_without_extension_is_detected() -> None:
31
+ assert _blocker_module.is_git_commit_shell_command(r"D:\bin\git commit -m note")
32
+
33
+
34
+ def test_quoted_windows_git_path_stays_detected_as_commit() -> None:
35
+ assert _blocker_module.is_git_commit_shell_command(
36
+ r'& "C:\Program Files\Git\cmd\git.exe" commit -m x'
37
+ )
38
+
39
+
40
+ def test_unquoted_windows_git_status_is_not_a_commit() -> None:
41
+ assert not _blocker_module.is_git_commit_shell_command(r"C:\tools\git.exe status")
@@ -0,0 +1,190 @@
1
+ """Behavior tests for the pure PII scanner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _HOOK_DIR = Path(__file__).parent
11
+ _HOOKS_DIR = _HOOK_DIR.parent
12
+ if str(_HOOK_DIR) not in sys.path:
13
+ sys.path.insert(0, str(_HOOK_DIR))
14
+ if str(_HOOKS_DIR) not in sys.path:
15
+ sys.path.insert(0, str(_HOOKS_DIR))
16
+
17
+ from pii_scanner import ( # noqa: E402
18
+ is_path_exempt_from_pii_scan,
19
+ scan_text_for_pii,
20
+ )
21
+
22
+ SYNTHETIC_GITHUB_TOKEN = "ghp_" + ("A" * 36)
23
+ SYNTHETIC_AWS_KEY = "AKIA" + ("B" * 16)
24
+ SYNTHETIC_PEM_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
25
+ SYNTHETIC_REAL_EMAIL = "person.fixture@company-example.io"
26
+ SYNTHETIC_SAFE_EMAIL = "user@example.com"
27
+ SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
28
+ SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
29
+ SYNTHETIC_PRIVATE_IP = "192.168.42.17"
30
+ SYNTHETIC_PUBLIC_IP = "8.8.8.8"
31
+ NAS_HOST_ENVIRONMENT_VARIABLE = "CLAUDE_NAS_HOST"
32
+ SYNTHETIC_NAS_PRIVATE_IP = "10.20.30.40"
33
+ SYNTHETIC_NAS_HOSTNAME = "nas.example.local"
34
+ SYNTHETIC_NAS_SSH_COMMAND = f"ssh -p 2200 operator@{SYNTHETIC_NAS_PRIVATE_IP} uptime"
35
+
36
+
37
+ def test_flags_real_email_and_allows_example_domain() -> None:
38
+ all_email_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
39
+ all_safe_hits = scan_text_for_pii(f"contact {SYNTHETIC_SAFE_EMAIL}")
40
+ assert any(each.category == "email" for each in all_email_hits)
41
+ assert all_email_hits[0].matched_text == SYNTHETIC_REAL_EMAIL
42
+ assert all_safe_hits == []
43
+
44
+
45
+ def test_flags_home_path_and_allows_placeholder_user() -> None:
46
+ all_home_hits = scan_text_for_pii(f"path is {SYNTHETIC_HOME_PATH}")
47
+ all_placeholder_hits = scan_text_for_pii(f"path is {SYNTHETIC_PLACEHOLDER_HOME}")
48
+ assert any(each.category == "home-path" for each in all_home_hits)
49
+ assert all_placeholder_hits == []
50
+
51
+
52
+ def test_allows_hygiene_placeholder_username_example() -> None:
53
+ assert scan_text_for_pii(r"path is C:\Users\example\notes.txt") == []
54
+ assert scan_text_for_pii("path is C:/Users/example/.claude/hooks") == []
55
+
56
+
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
+ )
61
+ assert any(each.category == "home-path" for each in all_unix_hits)
62
+
63
+
64
+ def test_flags_private_ip_and_allows_public_ip() -> None:
65
+ all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
66
+ all_dns_hits = scan_text_for_pii(f"dns {SYNTHETIC_PUBLIC_IP}")
67
+ assert any(each.category == "private-ip" for each in all_lan_hits)
68
+ assert all_lan_hits[0].matched_text == SYNTHETIC_PRIVATE_IP
69
+ assert all_dns_hits == []
70
+
71
+
72
+ def test_allows_loopback_unspecified_and_link_local_addresses() -> None:
73
+ assert scan_text_for_pii("http://127.0.0.1:8080/health") == []
74
+ assert scan_text_for_pii("Listen on 0.0.0.0:8000") == []
75
+ assert scan_text_for_pii("peer 169.254.10.20") == []
76
+
77
+
78
+ def test_allows_the_nas_host_resolved_from_local_configuration(
79
+ monkeypatch: pytest.MonkeyPatch,
80
+ ) -> None:
81
+ monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
82
+ assert scan_text_for_pii(SYNTHETIC_NAS_SSH_COMMAND) == []
83
+
84
+
85
+ def test_flags_private_ip_that_is_not_the_configured_nas_host(
86
+ monkeypatch: pytest.MonkeyPatch,
87
+ ) -> None:
88
+ monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_PRIVATE_IP)
89
+ all_lan_hits = scan_text_for_pii(f"peer {SYNTHETIC_PRIVATE_IP}")
90
+ assert [each.matched_text for each in all_lan_hits] == [SYNTHETIC_PRIVATE_IP]
91
+
92
+
93
+ def test_flags_private_ip_when_the_configured_nas_host_is_a_hostname(
94
+ monkeypatch: pytest.MonkeyPatch,
95
+ ) -> None:
96
+ monkeypatch.setenv(NAS_HOST_ENVIRONMENT_VARIABLE, SYNTHETIC_NAS_HOSTNAME)
97
+ all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
98
+ assert any(each.category == "private-ip" for each in all_lan_hits)
99
+
100
+
101
+ def test_flags_github_token_aws_key_and_pem_header() -> None:
102
+ all_github_hits = scan_text_for_pii(f"export TOKEN={SYNTHETIC_GITHUB_TOKEN}")
103
+ all_aws_hits = scan_text_for_pii(f"key={SYNTHETIC_AWS_KEY}")
104
+ all_pem_hits = scan_text_for_pii(SYNTHETIC_PEM_HEADER + "\nabc\n")
105
+ assert any(each.category == "secret" for each in all_github_hits)
106
+ assert any(each.category == "secret" for each in all_aws_hits)
107
+ assert any(each.category == "secret" for each in all_pem_hits)
108
+
109
+
110
+ def test_secret_and_email_previews_are_redacted() -> None:
111
+ all_secret_hits = scan_text_for_pii(f"export TOKEN={SYNTHETIC_GITHUB_TOKEN}")
112
+ all_email_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
113
+ secret_finding = next(
114
+ each for each in all_secret_hits if each.category == "secret"
115
+ )
116
+ email_finding = next(each for each in all_email_hits if each.category == "email")
117
+ assert secret_finding.matched_text == SYNTHETIC_GITHUB_TOKEN
118
+ assert SYNTHETIC_GITHUB_TOKEN not in secret_finding.preview
119
+ assert "…" in secret_finding.preview
120
+ assert email_finding.matched_text == SYNTHETIC_REAL_EMAIL
121
+ assert SYNTHETIC_REAL_EMAIL not in email_finding.preview
122
+ assert "…" in email_finding.preview
123
+
124
+
125
+ def test_allows_common_cloud_and_system_home_usernames() -> None:
126
+ assert scan_text_for_pii(r"C:\Users\ubuntu\notes.txt") == []
127
+ assert scan_text_for_pii(r"C:\Users\admin\notes.txt") == []
128
+ assert scan_text_for_pii("/home/runner/work/repo") == []
129
+ assert scan_text_for_pii("/home/container/app") == []
130
+
131
+
132
+ def test_flags_genuine_human_home_username() -> None:
133
+ all_human_hits = scan_text_for_pii(r"C:\Users\johnsmith\notes.txt")
134
+ assert any(each.category == "home-path" for each in all_human_hits)
135
+
136
+
137
+ def test_short_nonsafe_email_preview_is_fully_redacted() -> None:
138
+ all_email_hits = scan_text_for_pii("reach owner@acme.io")
139
+ email_finding = next(
140
+ each for each in all_email_hits if each.category == "email"
141
+ )
142
+ assert email_finding.matched_text == "owner@acme.io"
143
+ assert email_finding.preview == "[redacted]"
144
+ assert "owner" not in email_finding.preview
145
+
146
+
147
+ def test_home_path_preview_redacts_only_the_username() -> None:
148
+ all_home_hits = scan_text_for_pii(r"path is C:\Users\johnsmith\secret.txt")
149
+ home_finding = next(
150
+ each for each in all_home_hits if each.category == "home-path"
151
+ )
152
+ assert "johnsmith" not in home_finding.preview
153
+ assert "[redacted]" in home_finding.preview
154
+ assert "Users" in home_finding.preview
155
+
156
+
157
+ def test_home_path_preview_redacts_home_segment_when_name_repeats_earlier() -> None:
158
+ all_home_hits = scan_text_for_pii(r"path is C:\Users\Users\secret.txt")
159
+ home_finding = next(
160
+ each for each in all_home_hits if each.category == "home-path"
161
+ )
162
+ assert home_finding.preview == r"C:\Users\[redacted]"
163
+
164
+
165
+ def test_clean_prose_returns_no_findings() -> None:
166
+ prose = "Ship the fix. Use user@example.com in docs. Path is C:/Users/<you>/."
167
+ assert scan_text_for_pii(prose) == []
168
+
169
+
170
+ def test_path_exemptions_for_tests_license_and_self_modules() -> None:
171
+ assert is_path_exempt_from_pii_scan("packages/hooks/blocking/test_pii_scanner.py")
172
+ assert is_path_exempt_from_pii_scan("LICENSE")
173
+ assert is_path_exempt_from_pii_scan("LICENSE.md")
174
+ assert is_path_exempt_from_pii_scan("hooks/blocking/pii_scanner.py")
175
+ assert is_path_exempt_from_pii_scan(
176
+ "packages/claude-dev-env/hooks/hooks_constants/pii_prevention_constants.py"
177
+ )
178
+ assert not is_path_exempt_from_pii_scan("vendor/pii_scanner.py")
179
+ assert not is_path_exempt_from_pii_scan("not_hooks/blocking/pii_scanner.py")
180
+ assert not is_path_exempt_from_pii_scan("xhooks/blocking/pii_scanner.py")
181
+ assert not is_path_exempt_from_pii_scan("LICENSE_leak.env")
182
+ assert not is_path_exempt_from_pii_scan("src/app/settings.md")
183
+ assert not is_path_exempt_from_pii_scan("test_notes.md")
184
+ assert not is_path_exempt_from_pii_scan("test_secrets.env")
185
+ assert not is_path_exempt_from_pii_scan("")
186
+
187
+
188
+ def test_empty_path_still_scans_payload_text() -> None:
189
+ all_hits = scan_text_for_pii(f"contact {SYNTHETIC_REAL_EMAIL}")
190
+ assert any(each.category == "email" for each in all_hits)
@@ -33,7 +33,7 @@ def test_extract_pr_number_from_gh_pr_comment() -> None:
33
33
 
34
34
 
35
35
  def test_extract_pr_number_from_gh_pr_create_returns_none() -> None:
36
- command = 'gh pr create --repo jl-cmd/claude-code-config --body "some body"'
36
+ command = 'gh pr create --repo jl-cmd/claude-dev-env --body "some body"'
37
37
  assert hook_module._extract_pr_number_from_command(command) is None
38
38
 
39
39
 
@@ -20,9 +20,12 @@ from pathlib import Path
20
20
 
21
21
  import pytest
22
22
 
23
- _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
24
- if _HOOKS_DIR not in sys.path:
25
- sys.path.insert(0, _HOOKS_DIR)
23
+ _BLOCKING_DIR = Path(__file__).resolve().parent
24
+ _HOOKS_ROOT = _BLOCKING_DIR.parent
25
+ if str(_BLOCKING_DIR) not in sys.path:
26
+ sys.path.insert(0, str(_BLOCKING_DIR))
27
+ if str(_HOOKS_ROOT) not in sys.path:
28
+ sys.path.insert(0, str(_HOOKS_ROOT))
26
29
 
27
30
  from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
28
31
  ALL_HOSTED_HOOK_ENTRIES,
@@ -41,8 +44,6 @@ from pre_tool_use_dispatcher import ( # noqa: E402, I001
41
44
  run_hosted_hook,
42
45
  )
43
46
 
44
- _BLOCKING_DIR = Path(__file__).resolve().parent
45
- _HOOKS_ROOT = _BLOCKING_DIR.parent
46
47
  _DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
47
48
 
48
49
  _TEMP_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.txt")
@@ -632,8 +633,8 @@ def test_dispatcher_write_applies_both_groups() -> None:
632
633
  assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
633
634
  "plain_language_blocker (Group B) must be in Write applicable set"
634
635
  )
635
- assert len(all_write_entries) == 19, (
636
- f"Write tool must apply to all 19 hosted hooks, got {len(all_write_entries)}"
636
+ assert len(all_write_entries) == 20, (
637
+ f"Write tool must apply to all 20 hosted hooks, got {len(all_write_entries)}"
637
638
  )
638
639
 
639
640
 
@@ -646,16 +647,16 @@ def test_dispatcher_edit_applies_both_groups() -> None:
646
647
  assert "blocking/stale_comment_reference_blocker.py" in all_edit_script_paths, (
647
648
  "stale_comment_reference_blocker belongs in the Edit applicable set"
648
649
  )
649
- assert len(all_edit_entries) == 20, (
650
- f"expected 20 Edit entries, got {len(all_edit_entries)}"
650
+ assert len(all_edit_entries) == 21, (
651
+ f"expected 21 Edit entries, got {len(all_edit_entries)}"
651
652
  )
652
653
 
653
654
 
654
655
  def test_dispatcher_multi_edit_applies_only_group_b() -> None:
655
- """MultiEdit tool triggers only Group B (8 hooks), not Group A."""
656
+ """MultiEdit tool triggers only Group B (9 hooks), not Group A."""
656
657
  all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
657
- assert len(all_multi_edit_entries) == 8, (
658
- f"MultiEdit tool must apply to exactly 8 Group-B hooks, got {len(all_multi_edit_entries)}"
658
+ assert len(all_multi_edit_entries) == 9, (
659
+ f"MultiEdit tool must apply to exactly 9 Group-B hooks, got {len(all_multi_edit_entries)}"
659
660
  )
660
661
 
661
662
 
@@ -666,7 +667,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
666
667
  it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
667
668
  A Python Write payload that run_all_validators would have flagged (mypy errors, for
668
669
  instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
669
- dispatcher covers only its 18 hosted blocking hooks — none of which includes the
670
+ dispatcher covers only its 21 hosted blocking hooks — none of which includes the
670
671
  validators runner.
671
672
  """
672
673
  python_content_with_type_error = (
@@ -0,0 +1,187 @@
1
+ """Behavior tests for the Stop-hook dispatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
14
+ _BLOCKING_DIR = Path(__file__).resolve().parent
15
+ if _HOOKS_DIR not in sys.path:
16
+ sys.path.insert(0, _HOOKS_DIR)
17
+ _blocking_dir_text = str(_BLOCKING_DIR)
18
+ if _blocking_dir_text not in sys.path:
19
+ sys.path.insert(0, _blocking_dir_text)
20
+
21
+ from hooks_constants.hosted_hook_runner import HostedHookRun # noqa: E402
22
+ from stop_dispatcher import dispatch, select_first_block_stdout # noqa: E402
23
+
24
+ _DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "stop_dispatcher.py")
25
+
26
+
27
+ def _block_json(
28
+ reason: str, *, system_message: str = "", suppress_output: bool = False
29
+ ) -> str:
30
+ """Build a Stop block stdout string with optional supplementary fields."""
31
+ payload: dict[str, object] = {"decision": "block", "reason": reason}
32
+ if system_message:
33
+ payload["systemMessage"] = system_message
34
+ if suppress_output:
35
+ payload["suppressOutput"] = True
36
+ return json.dumps(payload)
37
+
38
+
39
+ def test_first_block_wins_among_non_crashed_runs() -> None:
40
+ """The first non-crashed block decision is the one the dispatcher emits."""
41
+ all_runs = [
42
+ HostedHookRun(captured_stdout="", did_crash=False),
43
+ HostedHookRun(captured_stdout=_block_json("first"), did_crash=False),
44
+ HostedHookRun(captured_stdout=_block_json("second"), did_crash=False),
45
+ ]
46
+ selected = select_first_block_stdout(all_runs)
47
+ assert json.loads(selected)["reason"] == "first"
48
+
49
+
50
+ def test_crashed_hook_before_block_is_skipped() -> None:
51
+ """A crash before a block fails open so a later block still surfaces."""
52
+ all_runs = [
53
+ HostedHookRun(captured_stdout="", did_crash=True),
54
+ HostedHookRun(captured_stdout=_block_json("later"), did_crash=False),
55
+ ]
56
+ selected = select_first_block_stdout(all_runs)
57
+ assert json.loads(selected)["reason"] == "later"
58
+
59
+
60
+ def test_no_block_when_every_hook_is_silent() -> None:
61
+ """A silent chain produces no block stdout."""
62
+ assert select_first_block_stdout([HostedHookRun("", False), HostedHookRun("", False)]) == ""
63
+
64
+
65
+ def test_select_preserves_system_message_and_suppress_output() -> None:
66
+ """select_first_block_stdout keeps supplementary fields on the winning block."""
67
+ all_runs = [
68
+ HostedHookRun(captured_stdout="", did_crash=True),
69
+ HostedHookRun(
70
+ captured_stdout=_block_json(
71
+ "keep fields",
72
+ system_message="rewrite the ending",
73
+ suppress_output=True,
74
+ ),
75
+ did_crash=False,
76
+ ),
77
+ ]
78
+ selected = json.loads(select_first_block_stdout(all_runs))
79
+ assert selected["reason"] == "keep fields"
80
+ assert selected["systemMessage"] == "rewrite the ending"
81
+ assert selected["suppressOutput"] is True
82
+
83
+
84
+ def test_dispatch_re_emits_first_block_with_system_message_and_suppress(
85
+ monkeypatch: pytest.MonkeyPatch,
86
+ capsys: pytest.CaptureFixture[str],
87
+ ) -> None:
88
+ """dispatch re-emits the first block's full JSON including supplementary fields."""
89
+ all_responses = [
90
+ HostedHookRun(captured_stdout="", did_crash=False),
91
+ HostedHookRun(
92
+ captured_stdout=_block_json(
93
+ "hedging blocked",
94
+ system_message="rewrite the ending",
95
+ suppress_output=True,
96
+ ),
97
+ did_crash=False,
98
+ ),
99
+ HostedHookRun(captured_stdout=_block_json("later block"), did_crash=False),
100
+ ]
101
+ response_iterator = iter(all_responses)
102
+
103
+ def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
104
+ del script_path, payload_text
105
+ return next(response_iterator)
106
+
107
+ monkeypatch.setattr("stop_dispatcher.run_hook_capturing_output", _fake_run_hook)
108
+ monkeypatch.setattr(
109
+ "stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
110
+ (
111
+ "blocking/hedging_language_blocker.py",
112
+ "blocking/question_to_user_enforcer.py",
113
+ "diagnostic/hook_log_stop_wrapper.py",
114
+ ),
115
+ )
116
+ dispatch('{"session_id": "test"}')
117
+ emitted_payload = json.loads(capsys.readouterr().out.strip())
118
+ assert emitted_payload["decision"] == "block"
119
+ assert emitted_payload["reason"] == "hedging blocked"
120
+ assert emitted_payload["systemMessage"] == "rewrite the ending"
121
+ assert emitted_payload["suppressOutput"] is True
122
+
123
+
124
+ def test_dispatch_writes_nothing_when_no_hook_blocks(
125
+ monkeypatch: pytest.MonkeyPatch,
126
+ capsys: pytest.CaptureFixture[str],
127
+ ) -> None:
128
+ """dispatch leaves stdout empty when every hosted hook is silent or allows."""
129
+ monkeypatch.setattr(
130
+ "stop_dispatcher.run_hook_capturing_output",
131
+ lambda script_path, payload_text: HostedHookRun(captured_stdout="", did_crash=False),
132
+ )
133
+ monkeypatch.setattr(
134
+ "stop_dispatcher.ALL_STOP_HOSTED_HOOK_PATHS",
135
+ ("blocking/hedging_language_blocker.py",),
136
+ )
137
+ dispatch('{"session_id": "test"}')
138
+ assert capsys.readouterr().out == ""
139
+
140
+
141
+ def test_dispatcher_exits_zero_on_empty_payload() -> None:
142
+ """The dispatcher exits zero when stdin is empty."""
143
+ completed = subprocess.run(
144
+ [sys.executable, _DISPATCHER_SCRIPT],
145
+ check=False,
146
+ input="",
147
+ capture_output=True,
148
+ text=True,
149
+ )
150
+ assert completed.returncode == 0
151
+ assert completed.stdout.strip() == ""
152
+
153
+
154
+ def test_dispatcher_blocks_hedging_message_matching_standalone() -> None:
155
+ """A hedging last_assistant_message blocks through the dispatcher."""
156
+ payload_text = json.dumps(
157
+ {
158
+ "stop_hook_active": False,
159
+ "last_assistant_message": "This is probably correct without a source.",
160
+ }
161
+ )
162
+ completed = subprocess.run(
163
+ [sys.executable, _DISPATCHER_SCRIPT],
164
+ check=False,
165
+ input=payload_text,
166
+ capture_output=True,
167
+ text=True,
168
+ encoding="utf-8",
169
+ )
170
+ assert completed.returncode == 0
171
+ parsed = json.loads(completed.stdout)
172
+ assert parsed["decision"] == "block"
173
+ assert "probably" in parsed["reason"].lower() or "hedging" in parsed["reason"].lower()
174
+
175
+
176
+ def test_dispatcher_imports_standalone_with_only_blocking_on_the_path() -> None:
177
+ """The dispatcher's bootstrap resolves hooks_constants without hooks/ on PYTHONPATH."""
178
+ subprocess_environment = {**os.environ, "PYTHONPATH": str(_BLOCKING_DIR)}
179
+ completed = subprocess.run(
180
+ [sys.executable, "-c", "import stop_dispatcher; print('ok')"],
181
+ check=False,
182
+ capture_output=True,
183
+ text=True,
184
+ env=subprocess_environment,
185
+ )
186
+ assert completed.returncode == 0, completed.stderr
187
+ assert completed.stdout.strip() == "ok"
@@ -78,7 +78,7 @@ def test_powershell_out_file_into_verdict_directory_is_flagged() -> None:
78
78
  def test_backslash_verdict_path_is_flagged() -> None:
79
79
  assert (
80
80
  references_verdict_directory(
81
- "echo forged > C:\\Users\\jon\\.claude\\verification\\abc.json"
81
+ "echo forged > C:\\Users\\example\\.claude\\verification\\abc.json"
82
82
  )
83
83
  is True
84
84
  )
@@ -31,7 +31,7 @@ def _body_names_volatile_path(tool_name: str, tool_input: dict[str, object]) ->
31
31
 
32
32
 
33
33
  def test_scan_detects_job_scratch_path_backslash() -> None:
34
- text = r"See C:\Users\jon\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"
34
+ text = r"See C:\Users\example\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"
35
35
  assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
36
36
 
37
37
 
@@ -46,7 +46,7 @@ def test_scan_detects_worktree_path() -> None:
46
46
 
47
47
 
48
48
  def test_scan_detects_appdata_temp_case_insensitive() -> None:
49
- text = r"C:\Users\jon\AppData\Local\Temp\bugteam\worktree"
49
+ text = r"C:\Users\example\AppData\Local\Temp\bugteam\worktree"
50
50
  assert scan_text_for_volatile_marker(text) == "appdata/local/temp"
51
51
 
52
52
 
@@ -74,7 +74,7 @@ def test_scan_clean_body_returns_none() -> None:
74
74
  def test_gh_comment_with_job_scratch_path_is_blocked() -> None:
75
75
  command = (
76
76
  'gh pr comment 669 --body "Contact sheet at '
77
- r'C:\Users\jon\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"'
77
+ r'C:\Users\example\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"'
78
78
  )
79
79
  assert _body_names_volatile_path("Bash", {"command": command})
80
80
 
@@ -220,11 +220,14 @@ def _collect_body_flag_values(
220
220
  return all_inline_bodies, all_body_file_paths
221
221
 
222
222
 
223
- def _read_body_file(body_file_path: str) -> str | None:
223
+ def _read_body_file(
224
+ body_file_path: str, working_directory: str | None = None
225
+ ) -> str | None:
224
226
  """Return the contents of a body-file path, or None when it cannot be read.
225
227
 
226
228
  Args:
227
229
  body_file_path: The path given to ``--body-file``/``-F``.
230
+ working_directory: Optional base directory for relative body-file paths.
228
231
 
229
232
  Returns:
230
233
  The file text, or None for an unresolvable shell value (such as ``-`` for
@@ -232,21 +235,28 @@ def _read_body_file(body_file_path: str) -> str | None:
232
235
  """
233
236
  if is_unresolvable_shell_value(body_file_path):
234
237
  return None
238
+ resolved_path = Path(body_file_path)
239
+ if not resolved_path.is_absolute() and working_directory:
240
+ resolved_path = Path(working_directory) / body_file_path
235
241
  try:
236
- return Path(body_file_path).read_text(encoding=BODY_FILE_ENCODING)
242
+ return resolved_path.read_text(encoding=BODY_FILE_ENCODING)
237
243
  except OSError:
238
244
  return None
239
245
 
240
246
 
241
- def extract_gh_post_body_texts(command: str) -> list[str]:
247
+ def extract_gh_post_body_texts(
248
+ command: str, working_directory: str | None = None
249
+ ) -> list[str]:
242
250
  """Return every post body text an affected ``gh`` command would send.
243
251
 
244
252
  Non-post ``gh`` commands and unparseable command lines yield an empty list.
245
253
  Body-file contents are read from disk so the embedded text is scanned rather
246
- than the file path.
254
+ than the file path. Relative body-file paths resolve against
255
+ *working_directory* when provided.
247
256
 
248
257
  Args:
249
258
  command: The raw Bash tool command string.
259
+ working_directory: Optional base directory for relative body-file paths.
250
260
 
251
261
  Returns:
252
262
  Inline ``--body`` strings plus the contents of each readable body-file.
@@ -265,12 +275,51 @@ def extract_gh_post_body_texts(command: str) -> list[str]:
265
275
  )
266
276
  all_body_texts = list(all_inline_bodies)
267
277
  for each_path in all_body_file_paths:
268
- file_text = _read_body_file(each_path)
278
+ file_text = _read_body_file(each_path, working_directory=working_directory)
269
279
  if file_text is not None:
270
280
  all_body_texts.append(file_text)
271
281
  return all_body_texts
272
282
 
273
283
 
284
+ def extract_gh_post_body_texts_for_privacy_gate(
285
+ command: str, working_directory: str | None = None
286
+ ) -> tuple[list[str], str | None]:
287
+ """Like ``extract_gh_post_body_texts``, but fail-closed on unreadable body-files.
288
+
289
+ Args:
290
+ command: The raw Bash tool command string.
291
+ working_directory: Optional base directory for relative body-file paths.
292
+
293
+ Returns:
294
+ ``(all_body_texts, None)`` when every declared body-file was read (or
295
+ none were declared), else ``([], deny_reason)`` when a body-file flag is
296
+ present but its contents could not be loaded for scanning.
297
+ """
298
+ logical_line = get_logical_first_line(command)
299
+ if not logical_line:
300
+ return [], None
301
+ try:
302
+ all_command_tokens = shlex.split(logical_line, posix=False)
303
+ except ValueError:
304
+ return [], None
305
+ if not _tokens_name_gh_post_command(all_command_tokens):
306
+ return [], None
307
+ all_inline_bodies, all_body_file_paths = _collect_body_flag_values(
308
+ all_command_tokens
309
+ )
310
+ all_body_texts = list(all_inline_bodies)
311
+ for each_path in all_body_file_paths:
312
+ file_text = _read_body_file(each_path, working_directory=working_directory)
313
+ if file_text is None:
314
+ return [], (
315
+ "BLOCKED [pii_prevention_blocker]: durable post uses --body-file "
316
+ f"but '{each_path}' could not be read for PII scanning. Use an "
317
+ "absolute path, ensure the file exists, or pass --body text."
318
+ )
319
+ all_body_texts.append(file_text)
320
+ return all_body_texts, None
321
+
322
+
274
323
  def extract_mcp_body_texts(all_tool_input: dict[str, object]) -> list[str]:
275
324
  """Return the body and comment strings from a GitHub MCP post tool input.
276
325