claude-dev-env 7.0.0 → 7.2.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +213 -0
  6. package/.agents/skills/session-continuity/SKILL.md +125 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +447 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +88 -0
  20. package/bin/install-session-continuity.test.mjs +55 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
7
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
8
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
9
+
10
+ from automatic_advisory.model import (
11
+ AdvisoryRegistration,
12
+ AdvisorySettings,
13
+ AdvisoryState,
14
+ )
15
+ from automatic_advisory.publisher import Publication
16
+ from automatic_advisory.runner import AdvisoryGitHub, AutomaticAdvisoryRunner
17
+ from automatic_advisory.state import read_state, write_state
18
+ from pr_verification.config.constants import LOCAL_CHECKS_PASSED_LABEL
19
+ from pr_verification.model import PullRequestCandidate, RepositorySettings
20
+
21
+
22
+ class ClosedPullRequestGitHub(AdvisoryGitHub):
23
+ def __init__(self, state_path: Path, failed_removal_count: int) -> None:
24
+ self.state_path = state_path
25
+ self.failed_removal_count = failed_removal_count
26
+ self.removed_labels: list[str] = []
27
+ self.observed_statuses: list[str | None] = []
28
+
29
+ def list_open_candidates(
30
+ self,
31
+ repository: RepositorySettings,
32
+ *,
33
+ should_require_merge_commit: bool = True,
34
+ ) -> tuple[PullRequestCandidate, ...]:
35
+ return ()
36
+
37
+ def remove_label(
38
+ self, repository: RepositorySettings, pull_request_number: int, label: str
39
+ ) -> None:
40
+ maybe_state = read_state(self.state_path)
41
+ self.observed_statuses.append(maybe_state.status if maybe_state else None)
42
+ self.removed_labels.append(label)
43
+ if self.failed_removal_count:
44
+ self.failed_removal_count -= 1
45
+ raise RuntimeError("label service unavailable")
46
+
47
+
48
+ def _build_registration(tmp_path: Path) -> AdvisoryRegistration:
49
+ return AdvisoryRegistration(
50
+ RepositorySettings("JonEcho/python-automation", "unused"),
51
+ 3082,
52
+ tmp_path / "checkout",
53
+ PurePosixPath("manifest.json"),
54
+ tmp_path / "report.json",
55
+ tmp_path / "state.json",
56
+ "main",
57
+ "origin",
58
+ )
59
+
60
+
61
+ def _build_runner(
62
+ registration: AdvisoryRegistration,
63
+ github: ClosedPullRequestGitHub,
64
+ ) -> AutomaticAdvisoryRunner:
65
+ settings = AdvisorySettings(
66
+ "https://api.github.com",
67
+ 4841271,
68
+ 159293880,
69
+ registration.state_path.with_name("private-key.pem"),
70
+ 60.0,
71
+ 30.0,
72
+ (registration,),
73
+ )
74
+ return AutomaticAdvisoryRunner(
75
+ settings,
76
+ github,
77
+ child_runner=_unexpected_child,
78
+ publisher=_unexpected_publisher,
79
+ clock=lambda: "2026-09-05T00:00:00+00:00",
80
+ )
81
+
82
+
83
+ def _unexpected_child(
84
+ *all_arguments: object,
85
+ **all_keyword_arguments: object,
86
+ ) -> object:
87
+ raise AssertionError("closed pull requests must not run checks")
88
+
89
+
90
+ def _unexpected_publisher(*all_arguments: object) -> Publication:
91
+ raise AssertionError("closed pull requests must not publish reports")
92
+
93
+
94
+ def _write_passed_state(registration: AdvisoryRegistration) -> None:
95
+ write_state(
96
+ registration.state_path,
97
+ AdvisoryState(
98
+ registration.repository.slug,
99
+ registration.pull_request_number,
100
+ "passed",
101
+ "local checks passed",
102
+ "old-head",
103
+ "base-head",
104
+ "2026-09-04T00:00:00+00:00",
105
+ "2026-09-09T00:00:00+00:00",
106
+ str(registration.report_path),
107
+ ),
108
+ )
109
+
110
+
111
+ def test_closed_pull_request_removes_stale_label_before_saving_closed_state(
112
+ tmp_path: Path,
113
+ ) -> None:
114
+ registration = _build_registration(tmp_path)
115
+ _write_passed_state(registration)
116
+ github = ClosedPullRequestGitHub(registration.state_path, 0)
117
+ runner = _build_runner(registration, github)
118
+
119
+ state = runner.run_once()[0]
120
+
121
+ assert state.status == "closed"
122
+ assert github.removed_labels == [LOCAL_CHECKS_PASSED_LABEL]
123
+ assert github.observed_statuses == ["passed"]
124
+ assert read_state(registration.state_path).status == "closed"
125
+
126
+
127
+ def test_closed_pull_request_retries_failed_label_removal(
128
+ tmp_path: Path,
129
+ ) -> None:
130
+ registration = _build_registration(tmp_path)
131
+ _write_passed_state(registration)
132
+ github = ClosedPullRequestGitHub(registration.state_path, 1)
133
+ runner = _build_runner(registration, github)
134
+
135
+ error_state = runner.run_once()[0]
136
+ closed_state = runner.run_once()[0]
137
+
138
+ assert error_state.status == "error"
139
+ assert closed_state.status == "closed"
140
+ assert github.removed_labels == [
141
+ LOCAL_CHECKS_PASSED_LABEL,
142
+ LOCAL_CHECKS_PASSED_LABEL,
143
+ ]
144
+ assert github.observed_statuses == ["passed", "error"]
145
+
146
+
147
+ def test_closed_pull_request_does_not_run_checks(
148
+ tmp_path: Path,
149
+ ) -> None:
150
+ registration = _build_registration(tmp_path)
151
+ github = ClosedPullRequestGitHub(registration.state_path, 0)
152
+ runner = _build_runner(registration, github)
153
+
154
+ state = runner.run_once()[0]
155
+
156
+ assert state.status == "closed"
157
+ assert github.removed_labels == [LOCAL_CHECKS_PASSED_LABEL]
@@ -462,13 +462,35 @@ def _apply_source_agent(source_root: Path, target_root: Path, description: str)
462
462
  return target_root / "Luna.toml"
463
463
 
464
464
 
465
- def _write_codex_hook_source(source_root: Path) -> None:
465
+ def _register_source_codex_enforcer(hooks_path: Path) -> None:
466
+ all_manifest = cast(
467
+ dict[str, object], json.loads(hooks_path.read_text(encoding="utf-8"))
468
+ )
469
+ all_events = cast(dict[str, object], all_manifest["hooks"])
470
+ all_pre_tool_use = cast(list[object], all_events["PreToolUse"])
471
+ all_pre_tool_use.append(
472
+ {
473
+ "matcher": materializer.codex_hook_matcher,
474
+ "hooks": [
475
+ {
476
+ "type": "command",
477
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
478
+ "timeout": materializer.codex_hook_timeout_seconds,
479
+ }
480
+ ],
481
+ }
482
+ )
483
+ hooks_path.write_text(json.dumps(all_manifest), encoding="utf-8")
484
+
485
+
486
+ def _write_codex_hook_source(source_root: Path, has_enforcer: bool) -> None:
466
487
  package_root = Path(__file__).parents[2]
467
488
  source_hooks = source_root / "hooks"
468
489
  source_hooks.mkdir(parents=True, exist_ok=True)
469
- (source_hooks / "hooks.json").write_bytes(
470
- (package_root / "hooks" / "hooks.json").read_bytes()
471
- )
490
+ hooks_path = source_hooks / "hooks.json"
491
+ hooks_path.write_bytes((package_root / "hooks" / "hooks.json").read_bytes())
492
+ if has_enforcer:
493
+ _register_source_codex_enforcer(hooks_path)
472
494
  for each_relative_path in materializer.codex_hook_dependency_manifest:
473
495
  source_path = source_root / each_relative_path
474
496
  source_path.parent.mkdir(parents=True, exist_ok=True)
@@ -479,7 +501,7 @@ def test_build_plan_rejects_reparse_hook_dependencies(
479
501
  tmp_path: Path,
480
502
  ) -> None:
481
503
  source = tmp_path / "source"
482
- _write_codex_hook_source(source)
504
+ _write_codex_hook_source(source, has_enforcer=True)
483
505
  dependency_path = source / materializer.codex_enforcer_script_relative_path
484
506
  outside_path = tmp_path / "outside.py"
485
507
  outside_path.write_text("secret", encoding="utf-8")
@@ -498,7 +520,7 @@ def test_publish_plan_preserves_modified_enforcer_hook_as_conflict(
498
520
  ) -> None:
499
521
  source = tmp_path / "source"
500
522
  target = tmp_path / "target"
501
- _write_codex_hook_source(source)
523
+ _write_codex_hook_source(source, has_enforcer=True)
502
524
  target.mkdir()
503
525
  modified_command = f'python3 "{target / materializer.codex_enforcer_script_relative_path}" --custom'
504
526
  existing_manifest = {
@@ -892,6 +914,7 @@ def _prepare_projection_fixture(
892
914
  (Path(__file__).parents[2] / "hooks" / "hooks.json").read_text(encoding="utf-8"),
893
915
  encoding="utf-8",
894
916
  )
917
+ _register_source_codex_enforcer(source_hooks)
895
918
  package_root = Path(__file__).parents[2]
896
919
  for each_relative_path in materializer.codex_hook_dependency_manifest:
897
920
  target_dependency = source / each_relative_path
@@ -1082,6 +1105,166 @@ def _assert_detached_enforcer_behavior(target: Path) -> None:
1082
1105
  assert target_entrypoint.is_file()
1083
1106
 
1084
1107
 
1108
+ def _managed_enforcer_hook(target: Path) -> dict[str, object]:
1109
+ return {
1110
+ "type": "command",
1111
+ "command": (
1112
+ f'python3 "{target / materializer.codex_enforcer_script_relative_path}"'
1113
+ ),
1114
+ "timeout": materializer.codex_hook_timeout_seconds,
1115
+ }
1116
+
1117
+
1118
+ def _foreign_same_basename_hook() -> dict[str, object]:
1119
+ return {
1120
+ "type": "command",
1121
+ "command": "python D:/custom/hooks/blocking/code_rules_enforcer.py",
1122
+ }
1123
+
1124
+
1125
+ def _codex_hook_group(
1126
+ *all_hook_records: dict[str, object],
1127
+ ) -> dict[str, object]:
1128
+ return {
1129
+ "matcher": materializer.codex_hook_matcher,
1130
+ "hooks": list(all_hook_records),
1131
+ }
1132
+
1133
+
1134
+ def _codex_hook_manifest(
1135
+ *all_hook_groups: dict[str, object],
1136
+ ) -> dict[str, object]:
1137
+ return {"hooks": {"PreToolUse": list(all_hook_groups)}}
1138
+
1139
+
1140
+ def _write_current_source_without_enforcer(source: Path) -> None:
1141
+ _write_codex_hook_source(source, has_enforcer=False)
1142
+ source_manifest = json.loads(
1143
+ (source / "hooks" / "hooks.json").read_text(encoding="utf-8")
1144
+ )
1145
+ all_source_commands = _hook_commands(source_manifest, "PreToolUse")
1146
+ assert not any(
1147
+ "code_rules_enforcer.py" in each_command
1148
+ for each_command in all_source_commands
1149
+ )
1150
+
1151
+
1152
+ def _publish_codex_hook_fixture(
1153
+ source: Path, target: Path, target_manifest: dict[str, object]
1154
+ ) -> tuple[dict[str, Any], MaterializationReport, list[PlannedFile]]:
1155
+ target.mkdir()
1156
+ target_hooks_path = target / materializer.codex_hook_manifest_target_path
1157
+ target_hooks_path.write_text(json.dumps(target_manifest), encoding="utf-8")
1158
+ config = MaterializerConfig(source, target, should_apply=True)
1159
+ planned, report = build_plan(config, all_agents=[])
1160
+ publication = publish_plan(config, planned, report)
1161
+ projected_manifest = json.loads(target_hooks_path.read_text(encoding="utf-8"))
1162
+ return projected_manifest, publication, planned
1163
+
1164
+
1165
+ def test_current_source_manifest_retires_the_managed_codex_enforcer(
1166
+ tmp_path: Path,
1167
+ ) -> None:
1168
+ source = tmp_path / "source"
1169
+ target = tmp_path / "target"
1170
+ _write_current_source_without_enforcer(source)
1171
+ foreign_hook: dict[str, object] = {
1172
+ "type": "command",
1173
+ "command": "python foreign_advisory.py",
1174
+ }
1175
+ foreign_named_hook = _foreign_same_basename_hook()
1176
+ target_manifest = _codex_hook_manifest(
1177
+ _codex_hook_group(_managed_enforcer_hook(target)),
1178
+ _codex_hook_group(foreign_hook, foreign_named_hook),
1179
+ )
1180
+
1181
+ projected_manifest, _, planned = _publish_codex_hook_fixture(
1182
+ source, target, target_manifest
1183
+ )
1184
+ assert projected_manifest["hooks"]["PreToolUse"] == [
1185
+ {
1186
+ "matcher": "apply_patch",
1187
+ "hooks": [foreign_hook, foreign_named_hook],
1188
+ }
1189
+ ]
1190
+ all_planned_paths = {each_file.target_relative_path for each_file in planned}
1191
+ assert all_planned_paths.isdisjoint(materializer.codex_hook_dependency_manifest)
1192
+
1193
+
1194
+ def test_explicit_source_enforcer_preserves_foreign_same_basename_hook(
1195
+ tmp_path: Path,
1196
+ ) -> None:
1197
+ source = tmp_path / "source"
1198
+ target = tmp_path / "target"
1199
+ _write_codex_hook_source(source, has_enforcer=True)
1200
+ managed_hook = _managed_enforcer_hook(target)
1201
+ foreign_hook = _foreign_same_basename_hook()
1202
+ target_manifest = _codex_hook_manifest(
1203
+ _codex_hook_group(managed_hook, foreign_hook)
1204
+ )
1205
+ projected_manifest, publication, _ = _publish_codex_hook_fixture(
1206
+ source, target, target_manifest
1207
+ )
1208
+ projected_hooks = projected_manifest["hooks"]["PreToolUse"][0]["hooks"]
1209
+ assert publication.conflicted == 0
1210
+ assert projected_hooks == [foreign_hook, managed_hook]
1211
+
1212
+
1213
+ @pytest.mark.parametrize("has_source_enforcer", [False, True])
1214
+ def test_posix_case_distinct_enforcer_path_remains_foreign(
1215
+ tmp_path: Path,
1216
+ monkeypatch: pytest.MonkeyPatch,
1217
+ has_source_enforcer: bool,
1218
+ ) -> None:
1219
+ source = tmp_path / "source"
1220
+ target = tmp_path / "target"
1221
+ if has_source_enforcer:
1222
+ _write_codex_hook_source(source, has_enforcer=True)
1223
+ if not has_source_enforcer:
1224
+ _write_current_source_without_enforcer(source)
1225
+ managed_hook = _managed_enforcer_hook(target)
1226
+ managed_command = cast(str, managed_hook["command"])
1227
+ foreign_command = managed_command.replace(str(target), str(target).upper())
1228
+ assert foreign_command != managed_command
1229
+ assert foreign_command.casefold() == managed_command.casefold()
1230
+ foreign_hook = {**managed_hook, "command": foreign_command}
1231
+ target_manifest = _codex_hook_manifest(
1232
+ _codex_hook_group(managed_hook, foreign_hook)
1233
+ )
1234
+ monkeypatch.setattr(materializer.os, "name", "posix")
1235
+
1236
+ projected_manifest, _, _ = _publish_codex_hook_fixture(
1237
+ source, target, target_manifest
1238
+ )
1239
+
1240
+ all_expected_commands = [foreign_command]
1241
+ if has_source_enforcer:
1242
+ all_expected_commands.append(managed_command)
1243
+ assert _hook_commands(projected_manifest, "PreToolUse") == all_expected_commands
1244
+
1245
+
1246
+ def test_windows_case_variant_enforcer_path_is_managed(
1247
+ tmp_path: Path,
1248
+ monkeypatch: pytest.MonkeyPatch,
1249
+ ) -> None:
1250
+ source = tmp_path / "source"
1251
+ target = tmp_path / "target"
1252
+ _write_current_source_without_enforcer(source)
1253
+ managed_hook = _managed_enforcer_hook(target)
1254
+ managed_command = cast(str, managed_hook["command"])
1255
+ case_variant_command = managed_command.replace(str(target), str(target).upper())
1256
+ target_manifest = _codex_hook_manifest(
1257
+ _codex_hook_group({**managed_hook, "command": case_variant_command})
1258
+ )
1259
+ monkeypatch.setattr(materializer.os, "name", "nt")
1260
+
1261
+ projected_manifest, _, _ = _publish_codex_hook_fixture(
1262
+ source, target, target_manifest
1263
+ )
1264
+
1265
+ assert projected_manifest["hooks"] == {}
1266
+
1267
+
1085
1268
  def test_exact_cli_publishes_instruction_and_additive_hook_projection(
1086
1269
  tmp_path: Path, capsys: pytest.CaptureFixture[str]
1087
1270
  ) -> None:
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
7
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
8
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
9
+
10
+ from pr_verification.github_labels import remove_label_if_present
11
+ from pr_verification.model import RepositorySettings
12
+
13
+ PASS_LABEL = "local-checks:passed"
14
+ FULL_PAGE_SIZE = 100
15
+
16
+
17
+ def repository_settings() -> RepositorySettings:
18
+ return RepositorySettings(
19
+ slug="owner/repository",
20
+ clone_url="https://github.test/owner/repository.git",
21
+ )
22
+
23
+
24
+ def label_page(all_label_names: list[str]) -> list[dict[str, str]]:
25
+ return [{"name": each_label_name} for each_label_name in all_label_names]
26
+
27
+
28
+ class RecordingLabelReader:
29
+ def __init__(self, all_pages: list[object]) -> None:
30
+ self.all_pages = all_pages
31
+ self.all_endpoints: list[str] = []
32
+
33
+ def __call__(self, endpoint: str) -> object:
34
+ self.all_endpoints.append(endpoint)
35
+ return self.all_pages.pop(0)
36
+
37
+
38
+ class RecordingLabelDeleter:
39
+ def __init__(self) -> None:
40
+ self.all_endpoints: list[str] = []
41
+
42
+ def __call__(self, endpoint: str) -> None:
43
+ self.all_endpoints.append(endpoint)
44
+
45
+
46
+ def test_removes_a_label_a_later_page_holds() -> None:
47
+ all_first_page_names = [
48
+ f"topic-{each_index}" for each_index in range(FULL_PAGE_SIZE)
49
+ ]
50
+ read_labels_page = RecordingLabelReader(
51
+ [label_page(all_first_page_names), label_page([PASS_LABEL])]
52
+ )
53
+ delete_label = RecordingLabelDeleter()
54
+
55
+ remove_label_if_present(
56
+ read_labels_page, delete_label, repository_settings(), 7, PASS_LABEL
57
+ )
58
+
59
+ assert read_labels_page.all_endpoints == [
60
+ "/repos/owner/repository/issues/7/labels?per_page=100&page=1",
61
+ "/repos/owner/repository/issues/7/labels?per_page=100&page=2",
62
+ ]
63
+ assert delete_label.all_endpoints == [
64
+ "/repos/owner/repository/issues/7/labels/local-checks%3Apassed"
65
+ ]
66
+
67
+
68
+ def test_reads_one_page_when_a_short_page_lacks_the_label() -> None:
69
+ read_labels_page = RecordingLabelReader([label_page(["topic"])])
70
+ delete_label = RecordingLabelDeleter()
71
+
72
+ remove_label_if_present(
73
+ read_labels_page, delete_label, repository_settings(), 7, PASS_LABEL
74
+ )
75
+
76
+ assert len(read_labels_page.all_endpoints) == 1
77
+ assert delete_label.all_endpoints == []
78
+
79
+
80
+ def test_reads_one_page_when_the_first_page_holds_the_label() -> None:
81
+ read_labels_page = RecordingLabelReader([label_page([PASS_LABEL])])
82
+ delete_label = RecordingLabelDeleter()
83
+
84
+ remove_label_if_present(
85
+ read_labels_page, delete_label, repository_settings(), 7, PASS_LABEL
86
+ )
87
+
88
+ assert len(read_labels_page.all_endpoints) == 1
89
+ assert len(delete_label.all_endpoints) == 1
90
+
91
+
92
+ def test_reads_no_further_page_when_a_page_is_not_a_list() -> None:
93
+ read_labels_page = RecordingLabelReader([{"message": "Not Found"}])
94
+ delete_label = RecordingLabelDeleter()
95
+
96
+ remove_label_if_present(
97
+ read_labels_page, delete_label, repository_settings(), 7, PASS_LABEL
98
+ )
99
+
100
+ assert len(read_labels_page.all_endpoints) == 1
101
+ assert delete_label.all_endpoints == []
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import sys
5
+ import urllib.error
6
+ import urllib.request
7
+ from collections.abc import Iterator
8
+ from contextlib import AbstractContextManager, contextmanager
9
+ from email.message import Message
10
+ from pathlib import Path
11
+ from unittest.mock import Mock
12
+
13
+ import jwt
14
+ import pytest
15
+
16
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
17
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
18
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
19
+
20
+ from pr_verification.config.timing import HTTP_TIMEOUT_SECONDS
21
+ from pr_verification.github_parsing import GitHubError
22
+ from pr_verification.github_transport import HttpReply, encode_app_jwt, request_http
23
+
24
+
25
+ @contextmanager
26
+ def reply_context(http_reply_mock: Mock) -> Iterator[Mock]:
27
+ yield http_reply_mock
28
+
29
+
30
+ def make_http_reply_mock(
31
+ status_code: int, headers: dict[str, str], body: bytes
32
+ ) -> Mock:
33
+ http_reply_mock = Mock()
34
+ http_reply_mock.status = status_code
35
+ http_reply_mock.headers.items.return_value = list(headers.items())
36
+ http_reply_mock.read.return_value = body
37
+ return http_reply_mock
38
+
39
+
40
+ def test_request_http_returns_success_reply_and_request_details(
41
+ monkeypatch: pytest.MonkeyPatch,
42
+ ) -> None:
43
+ http_reply_mock = make_http_reply_mock(
44
+ 200, {"Content-Type": "application/json"}, b'{"ok":true}'
45
+ )
46
+ all_requests: list[tuple[urllib.request.Request, float]] = []
47
+
48
+ def open_url(
49
+ request: urllib.request.Request, timeout: float
50
+ ) -> AbstractContextManager[Mock]:
51
+ all_requests.append((request, timeout))
52
+ return reply_context(http_reply_mock)
53
+
54
+ monkeypatch.setattr(urllib.request, "urlopen", open_url)
55
+
56
+ reply = request_http(
57
+ "GET",
58
+ "https://github.test/resource",
59
+ {"Accept": "application/json"},
60
+ None,
61
+ )
62
+
63
+ assert reply == HttpReply(200, {"Content-Type": "application/json"}, b'{"ok":true}')
64
+ assert all_requests[0][0].full_url == "https://github.test/resource"
65
+ assert all_requests[0][0].get_method() == "GET"
66
+ assert all_requests[0][1] == HTTP_TIMEOUT_SECONDS
67
+
68
+
69
+ def test_request_http_returns_http_error_reply(monkeypatch: pytest.MonkeyPatch) -> None:
70
+ http_headers = Message()
71
+ http_headers["X-Request-Id"] = "request-id"
72
+ http_error = urllib.error.HTTPError(
73
+ "https://github.test/resource",
74
+ 404,
75
+ "missing",
76
+ http_headers,
77
+ io.BytesIO(b"not found"),
78
+ )
79
+
80
+ def open_url(request: urllib.request.Request, timeout: float) -> Mock:
81
+ raise http_error
82
+
83
+ monkeypatch.setattr(urllib.request, "urlopen", open_url)
84
+
85
+ reply = request_http("GET", "https://github.test/resource", {}, None)
86
+
87
+ assert reply == HttpReply(404, {"X-Request-Id": "request-id"}, b"not found")
88
+
89
+
90
+ def test_request_http_propagates_url_error_as_github_error(
91
+ monkeypatch: pytest.MonkeyPatch,
92
+ ) -> None:
93
+ def open_url(request: urllib.request.Request, timeout: float) -> Mock:
94
+ raise urllib.error.URLError("offline")
95
+
96
+ monkeypatch.setattr(urllib.request, "urlopen", open_url)
97
+
98
+ with pytest.raises(GitHubError, match="offline"):
99
+ request_http("GET", "https://github.test/resource", {}, None)
100
+
101
+
102
+ def test_encode_app_jwt_preserves_arguments_and_normalizes_bytes_and_strings(
103
+ monkeypatch: pytest.MonkeyPatch,
104
+ ) -> None:
105
+ all_calls: list[tuple[dict[str, int | str], str, str]] = []
106
+ encoded_tokens = iter((b"encoded-bytes", "encoded-string"))
107
+
108
+ def encode_jwt(
109
+ claims: dict[str, int | str], private_key: str, algorithm: str
110
+ ) -> bytes | str:
111
+ all_calls.append((claims, private_key, algorithm))
112
+ return next(encoded_tokens)
113
+
114
+ monkeypatch.setattr(jwt, "encode", encode_jwt)
115
+ claims: dict[str, int | str] = {"iss": "42", "iat": 940, "exp": 1540}
116
+
117
+ bytes_token = encode_app_jwt(claims, "private-key", "RS256")
118
+ string_token = encode_app_jwt(claims, "private-key", "RS256")
119
+
120
+ assert bytes_token == "encoded-bytes"
121
+ assert string_token == "encoded-string"
122
+ assert all_calls == [
123
+ (claims, "private-key", "RS256"),
124
+ (claims, "private-key", "RS256"),
125
+ ]