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,91 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.error
4
+ import urllib.request
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from http.client import HTTPResponse as OpenedHttpReply
8
+
9
+ import jwt
10
+
11
+ from .config.constants import (
12
+ UTF8_ENCODING,
13
+ )
14
+ from .config.timing import HTTP_TIMEOUT_SECONDS
15
+ from .github_parsing import GitHubError
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class HttpReply:
20
+ status_code: int
21
+ headers: dict[str, str]
22
+ body: bytes
23
+
24
+
25
+ HttpRequester = Callable[[str, str, dict[str, str], bytes | None], HttpReply]
26
+ JwtEncoder = Callable[[dict[str, int | str], str, str], str]
27
+
28
+
29
+ def _read_http_error(error: urllib.error.HTTPError) -> HttpReply:
30
+ return HttpReply(
31
+ status_code=error.code,
32
+ headers=dict(error.headers.items()),
33
+ body=error.read(),
34
+ )
35
+
36
+
37
+ def request_http(
38
+ method: str,
39
+ url: str,
40
+ all_headers: dict[str, str],
41
+ body: bytes | None,
42
+ ) -> HttpReply:
43
+ """Send one GitHub HTTP request.
44
+
45
+ Args:
46
+ method: HTTP method.
47
+ url: Absolute request URL.
48
+ all_headers: Request headers.
49
+ body: Encoded request body, or None.
50
+
51
+ Returns:
52
+ The HTTP reply, including error replies with a status code.
53
+
54
+ Raises:
55
+ GitHubError: If the request fails before a status code exists.
56
+ """
57
+ request = urllib.request.Request(
58
+ url=url, data=body, headers=all_headers, method=method
59
+ )
60
+ try:
61
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as reply:
62
+ return _read_http_reply(reply)
63
+ except urllib.error.HTTPError as error:
64
+ return _read_http_error(error)
65
+ except (urllib.error.URLError, OSError) as error:
66
+ raise GitHubError(str(error)) from error
67
+
68
+
69
+ def _read_http_reply(reply: OpenedHttpReply) -> HttpReply:
70
+ return HttpReply(
71
+ status_code=reply.status,
72
+ headers=dict(reply.headers.items()),
73
+ body=reply.read(),
74
+ )
75
+
76
+
77
+ def encode_app_jwt(
78
+ all_claims: dict[str, int | str], private_key: str, algorithm: str
79
+ ) -> str:
80
+ """Encode an app JWT.
81
+
82
+ Args:
83
+ all_claims: JWT claims.
84
+
85
+ Returns:
86
+ Encoded token.
87
+ """
88
+ encoded_token = jwt.encode(all_claims, private_key, algorithm=algorithm)
89
+ if isinstance(encoded_token, bytes):
90
+ return encoded_token.decode(UTF8_ENCODING)
91
+ return encoded_token
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from types import TracebackType
7
+ from typing import Self
8
+
9
+ if sys.platform == "win32":
10
+ import msvcrt
11
+ else:
12
+ import fcntl
13
+
14
+ from .config.constants import (
15
+ SUPERVISOR_LOCK_ERROR,
16
+ SUPERVISOR_LOCK_FILE_MODE,
17
+ SUPERVISOR_LOCK_FILENAME,
18
+ SUPERVISOR_LOCK_IO_ERROR,
19
+ )
20
+
21
+ if sys.platform == "win32":
22
+
23
+ def _acquire_file_lock(file_descriptor: int) -> None:
24
+ """Acquire one nonblocking Windows lock."""
25
+ os.lseek(file_descriptor, 0, os.SEEK_SET)
26
+ msvcrt.locking(file_descriptor, msvcrt.LK_NBLCK, 1)
27
+
28
+ def _release_file_lock(file_descriptor: int) -> None:
29
+ """Release one Windows lock."""
30
+ os.lseek(file_descriptor, 0, os.SEEK_SET)
31
+ msvcrt.locking(file_descriptor, msvcrt.LK_UNLCK, 1)
32
+
33
+ else:
34
+
35
+ def _acquire_file_lock(file_descriptor: int) -> None:
36
+ """Acquire one nonblocking POSIX lock."""
37
+ fcntl.flock(file_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
38
+
39
+ def _release_file_lock(file_descriptor: int) -> None:
40
+ """Release one POSIX lock."""
41
+ fcntl.flock(file_descriptor, fcntl.LOCK_UN)
42
+
43
+
44
+ class SupervisorLockError(RuntimeError):
45
+ """Raised when the supervisor lock cannot be acquired or released."""
46
+
47
+
48
+ class SupervisorLock:
49
+ def __init__(self, cache_root: Path) -> None:
50
+ self.cache_root = cache_root.resolve()
51
+ self.lock_path = self.cache_root / SUPERVISOR_LOCK_FILENAME
52
+ self.file_descriptor: int | None = None
53
+
54
+ def __enter__(self) -> Self:
55
+ self.acquire()
56
+ return self
57
+
58
+ def __exit__(
59
+ self,
60
+ exception_type: type[BaseException] | None,
61
+ exception: BaseException | None,
62
+ traceback: TracebackType | None,
63
+ ) -> None:
64
+ self.release()
65
+
66
+ def acquire(self) -> None:
67
+ """Acquire the operating-system lock for this cache root.
68
+
69
+ Raises:
70
+ SupervisorLockError: If another owner holds the lock or I/O fails.
71
+ """
72
+ try:
73
+ self.cache_root.mkdir(parents=True, exist_ok=True)
74
+ file_descriptor = os.open(
75
+ self.lock_path,
76
+ os.O_CREAT | os.O_RDWR,
77
+ SUPERVISOR_LOCK_FILE_MODE,
78
+ )
79
+ except OSError as error:
80
+ raise SupervisorLockError(SUPERVISOR_LOCK_IO_ERROR) from error
81
+ try:
82
+ _acquire_file_lock(file_descriptor)
83
+ except (BlockingIOError, PermissionError) as error:
84
+ _close_file_descriptor_without_raising(file_descriptor)
85
+ raise SupervisorLockError(SUPERVISOR_LOCK_ERROR) from error
86
+ except OSError as error:
87
+ _close_file_descriptor_without_raising(file_descriptor)
88
+ raise SupervisorLockError(SUPERVISOR_LOCK_IO_ERROR) from error
89
+ self.file_descriptor = file_descriptor
90
+
91
+ def release(self) -> None:
92
+ """Release the operating-system lock and close its file descriptor.
93
+
94
+ Raises:
95
+ SupervisorLockError: If unlocking or closing the file fails.
96
+ """
97
+ file_descriptor = self.file_descriptor
98
+ if file_descriptor is None:
99
+ return
100
+ self.file_descriptor = None
101
+ try:
102
+ _release_file_lock(file_descriptor)
103
+ except OSError as error:
104
+ raise SupervisorLockError(SUPERVISOR_LOCK_IO_ERROR) from error
105
+ finally:
106
+ _close_file_descriptor(file_descriptor)
107
+
108
+
109
+ def _close_file_descriptor(file_descriptor: int) -> None:
110
+ """Close one lock file descriptor."""
111
+ try:
112
+ os.close(file_descriptor)
113
+ except OSError as error:
114
+ raise SupervisorLockError(SUPERVISOR_LOCK_IO_ERROR) from error
115
+
116
+
117
+ def _close_file_descriptor_without_raising(file_descriptor: int) -> None:
118
+ """Close one lock file descriptor while a lock failure is already being raised."""
119
+ try:
120
+ os.close(file_descriptor)
121
+ except OSError:
122
+ return
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+ from .config.constants import (
7
+ STATUS_ERROR,
8
+ STATUS_FAILURE,
9
+ STATUS_PENDING,
10
+ STATUS_SUCCESS,
11
+ )
12
+
13
+
14
+ class StatusState(str, Enum):
15
+ PENDING = STATUS_PENDING
16
+ ERROR = STATUS_ERROR
17
+ FAILURE = STATUS_FAILURE
18
+ SUCCESS = STATUS_SUCCESS
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RepositorySettings:
23
+ slug: str
24
+ clone_url: str
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ return self.slug.split("/", maxsplit=1)[1]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PullRequestCandidate:
33
+ repository_slug: str
34
+ pull_request_number: int
35
+ base_ref: str
36
+ base_sha: str
37
+ head_sha: str
38
+ merge_sha: str
39
+ is_draft: bool
40
+
41
+ @property
42
+ def key(self) -> tuple[str, int]:
43
+ return self.repository_slug, self.pull_request_number
@@ -0,0 +1 @@
1
+ PyJWT[crypto]>=2.10,<3
@@ -330,3 +330,100 @@ def test_cli_has_no_inline_body_flag() -> None:
330
330
  )
331
331
  assert completed_process.returncode == 2
332
332
  assert "unrecognized arguments" in completed_process.stderr
333
+
334
+
335
+ RELEASE_BRANCH = "release-please--branches--main--components--claude-dev-env"
336
+ RELEASE_BODY = """:robot: I have created a release *beep* *boop*
337
+ ---
338
+
339
+
340
+ ## [7.0.0](https://example.invalid/compare/v6.0.0...v7.0.0) (2026-01-01)
341
+
342
+
343
+ ### Features
344
+
345
+ * a shipped change
346
+
347
+ ---
348
+ This PR was generated with [Release Please](https://example.invalid/release-please).
349
+ """
350
+
351
+
352
+ def should_accept_a_release_body_without_the_house_headings() -> None:
353
+ all_findings = durable_post_lint.lint_durable_post(
354
+ action="pr-edit",
355
+ title=None,
356
+ body_text=RELEASE_BODY,
357
+ head_branch=RELEASE_BRANCH,
358
+ )
359
+ assert all_findings == ()
360
+
361
+
362
+ @pytest.mark.parametrize(
363
+ "damaged_body",
364
+ [
365
+ RELEASE_BODY.replace(":robot: I have created a release *beep* *boop*", ""),
366
+ RELEASE_BODY.replace("This PR was generated with [Release Please]", ""),
367
+ ],
368
+ )
369
+ def should_report_a_release_body_that_lost_a_marker(damaged_body: str) -> None:
370
+ all_findings = durable_post_lint.lint_durable_post(
371
+ action="pr-edit",
372
+ title=None,
373
+ body_text=damaged_body,
374
+ head_branch=RELEASE_BRANCH,
375
+ )
376
+ assert [each_finding.code for each_finding in all_findings] == [
377
+ "rewritten-release-body"
378
+ ]
379
+
380
+
381
+ def should_report_a_release_body_rewritten_into_house_prose() -> None:
382
+ all_findings = durable_post_lint.lint_durable_post(
383
+ action="pr-edit",
384
+ title=None,
385
+ body_text="This pull request publishes claude-dev-env 2.37.0.",
386
+ head_branch=RELEASE_BRANCH,
387
+ )
388
+ assert [each_finding.code for each_finding in all_findings] == [
389
+ "rewritten-release-body"
390
+ ]
391
+
392
+
393
+ def should_keep_the_house_headings_for_an_ordinary_branch() -> None:
394
+ all_findings = durable_post_lint.lint_durable_post(
395
+ action="pr-create",
396
+ title="fix(cli): tighten input handling",
397
+ body_text="Body without the required headings.",
398
+ head_branch="fix/ordinary-branch",
399
+ )
400
+ assert MISSING_HEADING_CODE_NAME in {
401
+ each_finding.code for each_finding in all_findings
402
+ }
403
+
404
+
405
+ MISSING_HEADING_CODE_NAME = "missing-pr-description-heading"
406
+
407
+
408
+ def should_report_a_rewritten_release_body_through_the_command_line(
409
+ tmp_path: Path,
410
+ ) -> None:
411
+ body_path = tmp_path / "body.md"
412
+ body_path.write_text("This pull request publishes the package.", encoding="utf-8")
413
+ completed_process = subprocess.run(
414
+ [
415
+ sys.executable,
416
+ str(SCRIPT_PATH),
417
+ "--action",
418
+ "pr-edit",
419
+ "--body-file",
420
+ str(body_path),
421
+ "--head-branch",
422
+ RELEASE_BRANCH,
423
+ ],
424
+ check=False,
425
+ capture_output=True,
426
+ text=True,
427
+ )
428
+ assert completed_process.returncode == 1
429
+ assert "keep the generated text whole" in completed_process.stderr
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import {
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import path from "node:path";
12
+ import test from "node:test";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16
+ const auditCliPath = path.join(packageRoot, "audit-hooks-cli.mjs");
17
+
18
+ function writeJson(filePath, jsonDocument) {
19
+ writeFileSync(filePath, JSON.stringify(jsonDocument), "utf8");
20
+ }
21
+
22
+ function createAuditFixture({
23
+ canonicalHooks = {},
24
+ claudeHooks = { hooks: {} },
25
+ codexHooks = { hooks: {} },
26
+ } = {}) {
27
+ const fixtureRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-cli-"));
28
+ const canonicalHooksRoot = path.join(fixtureRoot, "packages", "claude-dev-env", "hooks");
29
+ const homeDirectory = path.join(fixtureRoot, "home");
30
+ mkdirSync(canonicalHooksRoot, { recursive: true });
31
+ mkdirSync(path.join(homeDirectory, ".claude", "hooks"), { recursive: true });
32
+ mkdirSync(path.join(homeDirectory, ".codex", "hooks"), { recursive: true });
33
+ writeJson(path.join(canonicalHooksRoot, "hooks.json"), { hooks: canonicalHooks });
34
+ writeJson(path.join(homeDirectory, ".claude", "settings.json"), claudeHooks);
35
+ writeJson(path.join(homeDirectory, ".codex", "hooks.json"), codexHooks);
36
+ return { fixtureRoot, homeDirectory };
37
+ }
38
+
39
+ function runAuditCli(allArguments) {
40
+ const childProcess = spawnSync(process.execPath, [auditCliPath, ...allArguments], {
41
+ cwd: packageRoot,
42
+ encoding: "utf8",
43
+ env: { ...process.env, HOME: path.join(tmpdir(), "unused-audit-cli-home") },
44
+ shell: false,
45
+ });
46
+ assert.equal(childProcess.error, undefined);
47
+ return childProcess;
48
+ }
49
+
50
+ test("writes a JSON audit report to stdout through the CLI", () => {
51
+ const fixture = createAuditFixture({
52
+ canonicalHooks: {
53
+ PreToolUse: [{
54
+ matcher: "Write",
55
+ hooks: [{
56
+ type: "command",
57
+ command: "python3 " + "$" + "{CLAUDE_PLUGIN_ROOT}/hooks/blocking/write_existing_file_blocker.py",
58
+ }],
59
+ }],
60
+ },
61
+ });
62
+ try {
63
+ const childProcess = runAuditCli([
64
+ "--format",
65
+ "json",
66
+ "--repository-root",
67
+ fixture.fixtureRoot,
68
+ ]);
69
+
70
+ assert.equal(childProcess.status, 0);
71
+ assert.equal(childProcess.stderr, "");
72
+ const report = JSON.parse(childProcess.stdout);
73
+ assert.equal(report.summary.directCount, 1);
74
+ assert.equal(report.registrationEligibility.status, "not_checked");
75
+ } finally {
76
+ rmSync(fixture.fixtureRoot, { force: true, recursive: true });
77
+ }
78
+ });
79
+
80
+ test("writes the JSON audit report to the requested file through the CLI", () => {
81
+ const fixture = createAuditFixture();
82
+ const reportPath = path.join(fixture.fixtureRoot, "audit-report.json");
83
+ try {
84
+ const childProcess = runAuditCli([
85
+ "--format",
86
+ "json",
87
+ "--output",
88
+ reportPath,
89
+ "--repository-root",
90
+ fixture.fixtureRoot,
91
+ ]);
92
+
93
+ assert.equal(childProcess.status, 0);
94
+ assert.equal(childProcess.stdout, "");
95
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
96
+ assert.equal(report.summary.directCount, 0);
97
+ assert.equal(report.summary.findingCount, 0);
98
+ } finally {
99
+ rmSync(fixture.fixtureRoot, { force: true, recursive: true });
100
+ }
101
+ });
102
+
103
+ test("returns a strict nonblocking failure through the CLI", () => {
104
+ const fixture = createAuditFixture({
105
+ claudeHooks: {
106
+ hooks: {
107
+ SessionStart: [{
108
+ hooks: [{ command: "node cleanup.mjs", type: "command" }],
109
+ }],
110
+ },
111
+ },
112
+ });
113
+ const catalogPath = path.join(fixture.fixtureRoot, "lifecycle.json");
114
+ writeJson(catalogPath, {
115
+ hooks: {
116
+ "external:cleanup.mjs": {
117
+ lifecycle: "delete",
118
+ reason: "The installed entry is retired.",
119
+ replacement: null,
120
+ },
121
+ },
122
+ });
123
+ try {
124
+ const childProcess = runAuditCli([
125
+ "--format",
126
+ "json",
127
+ "--catalog",
128
+ catalogPath,
129
+ "--home",
130
+ fixture.homeDirectory,
131
+ "--installed",
132
+ "--require-nonblocking",
133
+ "--repository-root",
134
+ fixture.fixtureRoot,
135
+ ]);
136
+
137
+ assert.equal(childProcess.status, 1);
138
+ assert.equal(childProcess.stderr, "");
139
+ const report = JSON.parse(childProcess.stdout);
140
+ assert.equal(report.registrationEligibility.status, "ineligible");
141
+ assert.equal(
142
+ report.findings.some(({ code, target }) =>
143
+ code === "NONBLOCKING_LIFECYCLE_MISMATCH" && target === "external:cleanup.mjs",
144
+ ),
145
+ true,
146
+ );
147
+ } finally {
148
+ rmSync(fixture.fixtureRoot, { force: true, recursive: true });
149
+ }
150
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { spawnSync } from "node:child_process";
3
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
@@ -73,6 +73,37 @@ test("renders deterministic private JSON", () => {
73
73
  assert.equal(first.includes("python3 ${CLAUDE_PLUGIN_ROOT}"), false);
74
74
  });
75
75
 
76
+ test("does not compare the live graph with retired baseline counts", () => {
77
+ const report = auditHooks({ repositoryRoot });
78
+ assert.equal(report.summary.directCount, report.directRegistrations.length);
79
+ assert.equal(report.summary.effectiveCount, report.hostedRegistrations.length);
80
+ assert.equal(report.summary.logicalAssociationCount, report.logicalRegistrations.length);
81
+ assert.ok(report.summary.directCount > 0);
82
+ assert.ok(report.summary.effectiveCount > 0);
83
+ assert.ok(report.summary.logicalAssociationCount > report.summary.directCount);
84
+ assert.equal(report.findings.some(({ code }) => code === "DIRECT_COUNT_DRIFT"), false);
85
+ assert.equal(report.findings.some(({ code }) => code === "HOSTED_COUNT_DRIFT"), false);
86
+ });
87
+
88
+ test("does not execute registered commands", () => {
89
+ const fixture = createAuditFixture({
90
+ canonicalHooks: {
91
+ PreToolUse: [{
92
+ hooks: [{
93
+ command: "node -e \"require('node:fs').writeFileSync('audit-executed', 'yes')\"",
94
+ type: "command",
95
+ }],
96
+ }],
97
+ },
98
+ });
99
+ try {
100
+ auditHooks({ repositoryRoot: fixture.fixtureRoot });
101
+ assert.equal(existsSync(path.join(fixture.fixtureRoot, "audit-executed")), false);
102
+ } finally {
103
+ rmSync(fixture.fixtureRoot, { force: true, recursive: true });
104
+ }
105
+ });
106
+
76
107
  test("reports every unclassified target", () => {
77
108
  const temporaryRoot = mkdtempSync(path.join(tmpdir(), "hook-audit-"));
78
109
  const catalogPath = path.join(temporaryRoot, "lifecycle.json");
@@ -86,6 +117,15 @@ test("reports every unclassified target", () => {
86
117
  }
87
118
  });
88
119
 
120
+ test("reports canonical native Git hooks from the installer registry", () => {
121
+ const report = auditHooks({ repositoryRoot });
122
+ assert.deepEqual(
123
+ report.knownGitRegistrations.map(({ target }) => target),
124
+ ["git:pre-commit", "git:pre-push", "git:post-commit"],
125
+ );
126
+ assert.equal(report.summary.knownGitHookCount, 3);
127
+ });
128
+
89
129
  test("keeps each hosted matcher as narrow as its roster", () => {
90
130
  const fixture = createAuditFixture({
91
131
  canonicalHooks: {
@@ -161,6 +201,7 @@ test("classifies installed-only targets", () => {
161
201
  catalogPath,
162
202
  homeDirectory: fixture.homeDirectory,
163
203
  includeInstalled: true,
204
+ requireNonblocking: true,
164
205
  repositoryRoot: temporaryRoot,
165
206
  });
166
207
  const missingTargets = new Set(
@@ -266,6 +307,46 @@ test("reports missing installed dispatcher targets", () => {
266
307
  }
267
308
  });
268
309
 
310
+ test("rejects an installed-only registration without nonblocking lifecycle", () => {
311
+ const fixture = createAuditFixture({
312
+ claudeHooks: {
313
+ hooks: {
314
+ SessionStart: [{
315
+ hooks: [{ command: "node cleanup.mjs", type: "command" }],
316
+ }],
317
+ },
318
+ },
319
+ });
320
+ const catalogPath = path.join(fixture.fixtureRoot, "lifecycle.json");
321
+ writeJson(catalogPath, {
322
+ hooks: {
323
+ "external:cleanup.mjs": {
324
+ lifecycle: "delete",
325
+ reason: "The installed entry is retired.",
326
+ replacement: null,
327
+ },
328
+ },
329
+ });
330
+ try {
331
+ const report = auditHooks({
332
+ catalogPath,
333
+ homeDirectory: fixture.homeDirectory,
334
+ includeInstalled: true,
335
+ requireNonblocking: true,
336
+ repositoryRoot: fixture.fixtureRoot,
337
+ });
338
+ assert.equal(report.registrationEligibility.status, "ineligible");
339
+ assert.equal(
340
+ report.findings.some(({ code, target }) =>
341
+ code === "NONBLOCKING_LIFECYCLE_MISMATCH" && target === "external:cleanup.mjs",
342
+ ),
343
+ true,
344
+ );
345
+ } finally {
346
+ rmSync(fixture.fixtureRoot, { force: true, recursive: true });
347
+ }
348
+ });
349
+
269
350
  test("checks canonical managed parity for Codex registrations", () => {
270
351
  const fixture = createAuditFixture({
271
352
  canonicalHooks: {
@@ -355,6 +436,7 @@ test("finds default Git hooks from a worktree", () => {
355
436
  runGit(sourceRoot, ["commit", "--quiet", "--allow-empty", "-m", "fixture"], environment);
356
437
  runGit(sourceRoot, ["worktree", "add", "--quiet", worktreeRoot, "HEAD"], environment);
357
438
  writeHookFile(path.join(sourceRoot, ".git", "hooks"), "pre-commit");
439
+ writeHookFile(path.join(sourceRoot, ".git", "hooks"), "post-commit");
358
440
  const canonicalHooksRoot = path.join(worktreeRoot, "packages", "claude-dev-env", "hooks");
359
441
  const homeDirectory = path.join(sourceRoot, "home");
360
442
  mkdirSync(canonicalHooksRoot, { recursive: true });
@@ -366,9 +448,19 @@ test("finds default Git hooks from a worktree", () => {
366
448
  const report = auditHooks({
367
449
  homeDirectory,
368
450
  includeInstalled: true,
451
+ requireNonblocking: true,
369
452
  repositoryRoot: worktreeRoot,
370
453
  });
371
- assert.deepEqual(report.gitRegistrations.map(({ target }) => target), ["git:pre-commit"]);
454
+ assert.deepEqual(report.gitRegistrations.map(({ target }) => target), [
455
+ "git:pre-commit",
456
+ "git:post-commit",
457
+ ]);
458
+ assert.equal(
459
+ report.findings.some(({ code, target }) =>
460
+ code === "NATIVE_GIT_BLOCKING_HOOK" && target === "git:pre-commit",
461
+ ),
462
+ true,
463
+ );
372
464
  } finally {
373
465
  if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL;
374
466
  else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;