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
@@ -1,381 +0,0 @@
1
- """Unit tests for the fable_spawn_gate PreToolUse hook.
2
-
3
- Covers the decision table. A spawn at model ``fable`` whose prompt lacks the
4
- authorization marker is denied, and the deny reason points at the
5
- advisor-protocol document while carrying no copy of the token — one row holds
6
- the reason token-free, and its anti-corollary feeds that reason back as a
7
- retry prompt and expects a second denial. The same spawn carrying the marker
8
- is allowed. A spawn at any other model tier, and a spawn with no ``model``
9
- field, pass whether or not the marker is present. One test reads
10
- ``hooks.json`` and asserts the gate is registered on a matcher covering both
11
- spawn tool names.
12
-
13
- Token pins read the warm-up section and each consuming skill. Two doc-gate
14
- agreement tests assemble a spawn prompt out of the advisor reference wording a
15
- warm-up bind (``reference/warm-up.md``) and a drift re-spawn
16
- (``reference/lifecycle.md``) follow, and run each through the gate, so
17
- wording that stops naming the marker fails here. Two deny-path tests read the
18
- preview the gate hands the block logger and hold it bounded and scoped to the
19
- model field.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import importlib.util
25
- import io
26
- import json
27
- import pathlib
28
- import sys
29
- from typing import Any
30
- from unittest import mock
31
-
32
- import pytest
33
-
34
- _HOOK_DIR = pathlib.Path(__file__).parent
35
- _HOOKS_TREE = _HOOK_DIR.parent
36
- for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
37
- if each_path not in sys.path:
38
- sys.path.insert(0, each_path)
39
-
40
- hook_spec = importlib.util.spec_from_file_location(
41
- "fable_spawn_gate",
42
- _HOOK_DIR / "fable_spawn_gate.py",
43
- )
44
- assert hook_spec is not None
45
- assert hook_spec.loader is not None
46
- hook_module = importlib.util.module_from_spec(hook_spec)
47
- hook_spec.loader.exec_module(hook_module)
48
-
49
- from hooks_constants.fable_spawn_gate_constants import (
50
- ADVISOR_PROTOCOL_DOCUMENT_PATH,
51
- AGENT_TOOL_NAME,
52
- CALLING_HOOK_NAME,
53
- DENY_ADDITIONAL_CONTEXT,
54
- FABLE_MODEL_ALIAS,
55
- FABLE_SPAWN_AUTHORIZATION_MARKER,
56
- TASK_TOOL_NAME,
57
- )
58
-
59
- _ADVISOR_SUBAGENT_TYPE = "session-advisor"
60
- _OPUS_MODEL_ALIAS = "opus"
61
- _SONNET_MODEL_ALIAS = "sonnet"
62
- _TITLE_CASE_FABLE_MODEL = "Fable"
63
- _FULL_FABLE_MODEL_ID = "claude-fable-5"
64
- _FULL_SONNET_MODEL_ID = "claude-sonnet-4-5"
65
- _PACKAGE_ROOT = _HOOKS_TREE.parent
66
- _ADVISOR_PROTOCOL_PATH = _PACKAGE_ROOT / "_shared" / "advisor" / "advisor-protocol.md"
67
- _ADVISOR_PROTOCOL_TEXT = _ADVISOR_PROTOCOL_PATH.read_text(encoding="utf-8")
68
- _ADVISOR_REFERENCE_DIR = _PACKAGE_ROOT / "_shared" / "advisor" / "reference"
69
- _ADVISOR_LIFECYCLE_TEXT = (_ADVISOR_REFERENCE_DIR / "lifecycle.md").read_text(
70
- encoding="utf-8"
71
- )
72
- _ADVISOR_WARM_UP_TEXT = (_ADVISOR_REFERENCE_DIR / "warm-up.md").read_text(
73
- encoding="utf-8"
74
- )
75
- _ALL_CONSUMING_SKILL_NAMES = ("team-advisor",)
76
- _ALL_CONSUMING_SKILL_PATHS = tuple(
77
- _PACKAGE_ROOT / ".agents" / "skills" / each_skill_name / "SKILL.md"
78
- for each_skill_name in _ALL_CONSUMING_SKILL_NAMES
79
- )
80
- _skill_pin_ids = _ALL_CONSUMING_SKILL_NAMES
81
- _BIND_SECTION_HEADING = "## Warm-up (once per session)"
82
- _MARKDOWN_SECTION_HEADING_PREFIX = "\n## "
83
- _RESPAWN_PARAGRAPH_MARKER = "**Re-spawn on drift.**"
84
- _WARM_UP_PROMPT_BULLET_MARKER = "- `prompt`: the charter below."
85
- _PARAGRAPH_SEPARATOR = "\n\n"
86
- _MULTI_KILOBYTE_PROMPT = "Audit the changed lines and report each finding. " * 200
87
- _MAXIMUM_DENY_PREVIEW_LENGTH = 120
88
- _MARKER_STATE_FIELD_NAME = "marker_present"
89
- _PROMPT_BODY_SAMPLE = "Audit the changed lines"
90
- _UNMARKED_PROMPT = "Audit the changed lines and report each finding with its path and line."
91
- _ADVISOR_BIND_PROMPT = (
92
- "You are the standing session advisor for this run. "
93
- f"{FABLE_SPAWN_AUTHORIZATION_MARKER} "
94
- "Answer every consult with ENDORSE, CORRECTION, PLAN, or STOP."
95
- )
96
-
97
-
98
- def _spawn_payload(
99
- *,
100
- tool_name: str = AGENT_TOOL_NAME,
101
- model: str | None = FABLE_MODEL_ALIAS,
102
- prompt: str = _UNMARKED_PROMPT,
103
- ) -> dict[str, Any]:
104
- """Build a PreToolUse spawn payload for the decision table.
105
-
106
- Args:
107
- tool_name: The spawning tool name.
108
- model: The model alias, or None to leave the field out entirely.
109
- prompt: The spawn prompt text.
110
-
111
- Returns:
112
- The payload mapping a hook reads from stdin.
113
- """
114
- all_tool_input: dict[str, Any] = {
115
- "subagent_type": _ADVISOR_SUBAGENT_TYPE,
116
- "description": "Bind the session advisor",
117
- "prompt": prompt,
118
- }
119
- if model is not None:
120
- all_tool_input["model"] = model
121
- return {"tool_name": tool_name, "tool_input": all_tool_input}
122
-
123
-
124
- def _run_main_with_io(input_text: str) -> str:
125
- """Run the gate's main against stdin text and return its stdout.
126
-
127
- Args:
128
- input_text: The raw stdin text the gate reads.
129
-
130
- Returns:
131
- Everything the gate wrote to stdout — empty on an allow.
132
- """
133
- with mock.patch("sys.stdin", io.StringIO(input_text)):
134
- with mock.patch("sys.stdout", new_callable=io.StringIO) as captured_stdout:
135
- try:
136
- hook_module.main()
137
- except SystemExit:
138
- pass
139
- return captured_stdout.getvalue()
140
-
141
-
142
- def _decision_for(payload: dict[str, Any]) -> str:
143
- """Return the gate's permissionDecision for one spawn payload.
144
-
145
- Args:
146
- payload: The PreToolUse payload to feed the gate.
147
-
148
- Returns:
149
- The decision string from the deny payload.
150
- """
151
- captured_output = _run_main_with_io(json.dumps(payload))
152
- return json.loads(captured_output)["hookSpecificOutput"]["permissionDecision"]
153
-
154
-
155
- def test_should_deny_fable_spawn_without_the_marker() -> None:
156
- assert _decision_for(_spawn_payload()) == "deny"
157
-
158
-
159
- def test_should_allow_fable_spawn_carrying_the_marker() -> None:
160
- payload = _spawn_payload(prompt=_ADVISOR_BIND_PROMPT)
161
- assert _run_main_with_io(json.dumps(payload)) == ""
162
-
163
-
164
- def test_should_allow_opus_spawn_without_the_marker() -> None:
165
- payload = _spawn_payload(model=_OPUS_MODEL_ALIAS)
166
- assert _run_main_with_io(json.dumps(payload)) == ""
167
-
168
-
169
- def test_should_allow_sonnet_spawn_carrying_the_marker() -> None:
170
- payload = _spawn_payload(model=_SONNET_MODEL_ALIAS, prompt=_ADVISOR_BIND_PROMPT)
171
- assert _run_main_with_io(json.dumps(payload)) == ""
172
-
173
-
174
- def test_should_allow_spawn_with_no_model_field() -> None:
175
- payload = _spawn_payload(model=None)
176
- assert _run_main_with_io(json.dumps(payload)) == ""
177
-
178
-
179
- def test_should_deny_title_case_fable_spawn_without_the_marker() -> None:
180
- assert _decision_for(_spawn_payload(model=_TITLE_CASE_FABLE_MODEL)) == "deny"
181
-
182
-
183
- def test_should_deny_full_model_id_fable_spawn_without_the_marker() -> None:
184
- assert _decision_for(_spawn_payload(model=_FULL_FABLE_MODEL_ID)) == "deny"
185
-
186
-
187
- def test_should_allow_full_model_id_fable_spawn_carrying_the_marker() -> None:
188
- payload = _spawn_payload(model=_FULL_FABLE_MODEL_ID, prompt=_ADVISOR_BIND_PROMPT)
189
- assert _run_main_with_io(json.dumps(payload)) == ""
190
-
191
-
192
- def test_should_allow_full_model_id_sonnet_spawn_without_the_marker() -> None:
193
- payload = _spawn_payload(model=_FULL_SONNET_MODEL_ID)
194
- assert _run_main_with_io(json.dumps(payload)) == ""
195
-
196
-
197
- def test_should_deny_fable_task_spawn_without_the_marker() -> None:
198
- assert _decision_for(_spawn_payload(tool_name=TASK_TOOL_NAME)) == "deny"
199
-
200
-
201
- def test_should_deny_fable_spawn_whose_prompt_field_is_absent() -> None:
202
- payload: dict[str, Any] = {
203
- "tool_name": AGENT_TOOL_NAME,
204
- "tool_input": {
205
- "subagent_type": _ADVISOR_SUBAGENT_TYPE,
206
- "model": FABLE_MODEL_ALIAS,
207
- },
208
- }
209
- assert _decision_for(payload) == "deny"
210
-
211
-
212
- def _deny_reason_for(payload: dict[str, Any]) -> str:
213
- """Return the permissionDecisionReason the gate writes for one payload.
214
-
215
- Args:
216
- payload: The PreToolUse payload the gate denies.
217
-
218
- Returns:
219
- The deny reason text.
220
- """
221
- captured_output = _run_main_with_io(json.dumps(payload))
222
- return str(
223
- json.loads(captured_output)["hookSpecificOutput"]["permissionDecisionReason"]
224
- )
225
-
226
-
227
- def test_deny_reason_names_the_document_without_the_token() -> None:
228
- deny_reason = _deny_reason_for(_spawn_payload())
229
- assert ADVISOR_PROTOCOL_DOCUMENT_PATH in deny_reason
230
- assert FABLE_SPAWN_AUTHORIZATION_MARKER not in deny_reason
231
-
232
-
233
- def _additional_context_for(payload: dict[str, Any]) -> str:
234
- """Return hookSpecificOutput.additionalContext for a denied spawn."""
235
- captured_output = _run_main_with_io(json.dumps(payload))
236
- return str(json.loads(captured_output)["hookSpecificOutput"]["additionalContext"])
237
-
238
-
239
- def test_deny_payload_carries_additional_context_for_the_spawner() -> None:
240
- """A denial injects recovery steps into additionalContext for the spawner."""
241
- additional_context = _additional_context_for(_spawn_payload())
242
- assert additional_context == DENY_ADDITIONAL_CONTEXT
243
- assert "fable-spawn-gate" in additional_context
244
- assert "opus" in additional_context.lower()
245
- assert "opus-equivalent" in additional_context.lower()
246
- assert ADVISOR_PROTOCOL_DOCUMENT_PATH in additional_context
247
- assert FABLE_SPAWN_AUTHORIZATION_MARKER not in additional_context
248
-
249
-
250
- def test_spawn_prompt_carrying_the_deny_text_still_denies() -> None:
251
- deny_reason = _deny_reason_for(_spawn_payload())
252
- assert _decision_for(_spawn_payload(prompt=deny_reason)) == "deny"
253
-
254
-
255
- def test_should_allow_when_the_payload_is_malformed_json() -> None:
256
- assert _run_main_with_io("not valid json {{{") == ""
257
-
258
-
259
- def _denial_preview_for(payload: dict[str, Any]) -> str:
260
- """Return the offending-input preview the gate hands the block logger.
261
-
262
- Args:
263
- payload: The PreToolUse payload the gate denies.
264
-
265
- Returns:
266
- The ``offending_input_preview`` argument of the logged block.
267
- """
268
- with mock.patch.object(hook_module, "log_hook_block") as recorded_block:
269
- _run_main_with_io(json.dumps(payload))
270
- return str(recorded_block.call_args.kwargs["offending_input_preview"])
271
-
272
-
273
- def test_deny_preview_stays_bounded_on_a_multi_kilobyte_prompt() -> None:
274
- preview = _denial_preview_for(_spawn_payload(prompt=_MULTI_KILOBYTE_PROMPT))
275
- assert len(preview) <= _MAXIMUM_DENY_PREVIEW_LENGTH
276
-
277
-
278
- def test_deny_preview_carries_the_model_and_marker_state_without_the_prompt() -> None:
279
- preview = _denial_preview_for(_spawn_payload(prompt=_MULTI_KILOBYTE_PROMPT))
280
- assert FABLE_MODEL_ALIAS in preview
281
- assert _MARKER_STATE_FIELD_NAME in preview
282
- assert _PROMPT_BODY_SAMPLE not in preview
283
-
284
-
285
- def _matchers_registering_the_gate() -> list[str]:
286
- """Return every PreToolUse matcher whose group runs this gate.
287
-
288
- Returns:
289
- The matcher strings of the registration groups naming the gate script.
290
- """
291
- manifest_record = json.loads(
292
- (_HOOKS_TREE / "hooks.json").read_text(encoding="utf-8")
293
- )
294
- all_matchers: list[str] = []
295
- for each_group in manifest_record["hooks"]["PreToolUse"]:
296
- for each_hook in each_group.get("hooks", []):
297
- if CALLING_HOOK_NAME in each_hook.get("command", ""):
298
- all_matchers.append(each_group.get("matcher", ""))
299
- return all_matchers
300
-
301
-
302
- def test_gate_is_registered_on_a_matcher_covering_both_spawn_tools() -> None:
303
- all_matchers = _matchers_registering_the_gate()
304
- assert len(all_matchers) == 1
305
- assert AGENT_TOOL_NAME in all_matchers[0]
306
- assert TASK_TOOL_NAME in all_matchers[0]
307
-
308
-
309
- def _bind_section_text() -> str:
310
- """Return the advisor-protocol warm-up section that documents the bind.
311
-
312
- Returns:
313
- The section text running from its heading to the next heading.
314
- """
315
- section_start = _ADVISOR_PROTOCOL_TEXT.index(_BIND_SECTION_HEADING)
316
- section_body = _ADVISOR_PROTOCOL_TEXT[section_start + len(_BIND_SECTION_HEADING) :]
317
- next_heading_start = section_body.find(_MARKDOWN_SECTION_HEADING_PREFIX)
318
- if next_heading_start < 0:
319
- return section_body
320
- return section_body[:next_heading_start]
321
-
322
-
323
- def test_advisor_protocol_bind_section_names_the_marker_token() -> None:
324
- assert FABLE_SPAWN_AUTHORIZATION_MARKER in _bind_section_text()
325
-
326
-
327
- @pytest.mark.parametrize("skill_path", _ALL_CONSUMING_SKILL_PATHS, ids=_skill_pin_ids)
328
- def test_consuming_skill_names_the_marker_token(skill_path: pathlib.Path) -> None:
329
- assert FABLE_SPAWN_AUTHORIZATION_MARKER in skill_path.read_text(encoding="utf-8")
330
-
331
-
332
- def _paragraph_starting_at(source_text: str, paragraph_marker: str) -> str:
333
- """Return the advisor-doc paragraph that opens with a marker.
334
-
335
- Args:
336
- source_text: The advisor document text holding the paragraph.
337
- paragraph_marker: The literal text opening the paragraph.
338
-
339
- Returns:
340
- The paragraph text, running from that marker to the blank line that
341
- closes it.
342
- """
343
- paragraph_body = source_text[source_text.index(paragraph_marker) :]
344
- paragraph_end = paragraph_body.find(_PARAGRAPH_SEPARATOR)
345
- if paragraph_end < 0:
346
- return paragraph_body
347
- return paragraph_body[:paragraph_end]
348
-
349
-
350
- def _respawn_spawn_prompt() -> str:
351
- """Assemble the spawn prompt a drift re-spawn writes from the lifecycle doc.
352
-
353
- The prompt comes from the re-spawn paragraph alone, so the gate reads
354
- what that one paragraph tells a session to send.
355
-
356
- Returns:
357
- The spawn prompt text a session following that paragraph sends.
358
- """
359
- return _paragraph_starting_at(_ADVISOR_LIFECYCLE_TEXT, _RESPAWN_PARAGRAPH_MARKER)
360
-
361
-
362
- def test_respawn_paragraph_prompt_passes_the_gate_at_the_fable_tier() -> None:
363
- payload = _spawn_payload(prompt=_respawn_spawn_prompt())
364
- assert _run_main_with_io(json.dumps(payload)) == ""
365
-
366
-
367
- def _warm_up_spawn_prompt() -> str:
368
- """Assemble the spawn prompt a warm-up bind writes from the warm-up doc.
369
-
370
- The prompt comes from the spawn-field prompt bullet alone, so the gate
371
- reads what that one bullet tells a session to send.
372
-
373
- Returns:
374
- The spawn prompt text a session following that bullet sends.
375
- """
376
- return _paragraph_starting_at(_ADVISOR_WARM_UP_TEXT, _WARM_UP_PROMPT_BULLET_MARKER)
377
-
378
-
379
- def test_warm_up_prompt_bullet_passes_the_gate_at_the_fable_tier() -> None:
380
- payload = _spawn_payload(prompt=_warm_up_spawn_prompt())
381
- assert _run_main_with_io(json.dumps(payload)) == ""
@@ -1,154 +0,0 @@
1
- """End-to-end tests for the inventory/orphan pending-intent deadlock resolution.
2
-
3
- Each test drives the two real hook binaries through their production ``main()``
4
- stdin path over a temp directory tree whose ``CLAUDE.md`` inventory names two
5
- sibling files. A shared ``session_id`` and an isolated ``TMPDIR`` make the two
6
- hooks read one shared intent-records file, so a deny by one hook opens the gate
7
- for the sibling's second write — in either add order.
8
- """
9
-
10
- import json
11
- import os
12
- import subprocess
13
- import sys
14
- from pathlib import Path
15
-
16
- BLOCKING_DIRECTORY = Path(__file__).resolve().parent
17
-
18
- INVENTORY_HOOK = "package_inventory_stale_blocker.py"
19
- ORPHAN_HOOK = "claude_md_orphan_file_blocker.py"
20
-
21
- SESSION_ID = "deadlock-session"
22
-
23
- INVENTORY_TABLE_HEADER = "# pkg\n\n| File | Purpose |\n|---|---|\n"
24
- ALPHA_ROW = "| `alpha.py` | Does A |\n"
25
- BETA_ROW = "| `beta.py` | Does B |\n"
26
- GAMMA_ROW = "| `gamma.py` | Does G |\n"
27
- EPSILON_ROW = "| `epsilon.py` | Does E |\n"
28
-
29
- BASE_INVENTORY = INVENTORY_TABLE_HEADER + ALPHA_ROW + BETA_ROW
30
- INVENTORY_WITH_GAMMA = BASE_INVENTORY + GAMMA_ROW
31
- INVENTORY_WITH_EPSILON = BASE_INVENTORY + EPSILON_ROW
32
-
33
- MODULE_BODY = "x = 1\n"
34
-
35
-
36
- def _package_directory(tmp_path: Path) -> Path:
37
- """Return a package directory holding a CLAUDE.md table naming two siblings."""
38
- package_directory = tmp_path / "pkg"
39
- package_directory.mkdir()
40
- (package_directory / "CLAUDE.md").write_text(BASE_INVENTORY, encoding="utf-8")
41
- (package_directory / "alpha.py").write_text(MODULE_BODY, encoding="utf-8")
42
- (package_directory / "beta.py").write_text(MODULE_BODY, encoding="utf-8")
43
- return package_directory
44
-
45
-
46
- def _run_hook(
47
- hook_filename: str, tool_name: str, tool_input: dict, temp_directory: Path
48
- ) -> subprocess.CompletedProcess:
49
- """Run one hook binary over a payload, sharing an isolated intent-records store."""
50
- payload = json.dumps(
51
- {"tool_name": tool_name, "tool_input": tool_input, "session_id": SESSION_ID}
52
- )
53
- hook_environment = dict(os.environ)
54
- hook_environment["TMPDIR"] = str(temp_directory)
55
- return subprocess.run(
56
- [sys.executable, str(BLOCKING_DIRECTORY / hook_filename)],
57
- input=payload,
58
- capture_output=True,
59
- text=True,
60
- check=False,
61
- env=hook_environment,
62
- )
63
-
64
-
65
- def _decision(completed: subprocess.CompletedProcess) -> str | None:
66
- """Return the permission decision a hook emitted, or None when it allowed."""
67
- if not completed.stdout.strip():
68
- return None
69
- return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecision"]
70
-
71
-
72
- def _reason(completed: subprocess.CompletedProcess) -> str:
73
- """Return the deny reason a hook emitted."""
74
- return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecisionReason"]
75
-
76
-
77
- def test_file_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
78
- package_directory = _package_directory(tmp_path)
79
- new_file = package_directory / "gamma.py"
80
- claude_md = package_directory / "CLAUDE.md"
81
-
82
- first_write = _run_hook(
83
- INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
84
- )
85
- assert _decision(first_write) == "deny"
86
- assert "gamma.py" in _reason(first_write)
87
- assert "recorded" in _reason(first_write)
88
-
89
- row_write = _run_hook(
90
- ORPHAN_HOOK,
91
- "Write",
92
- {"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
93
- tmp_path,
94
- )
95
- assert _decision(row_write) is None
96
-
97
- claude_md.write_text(INVENTORY_WITH_GAMMA, encoding="utf-8")
98
- retry_write = _run_hook(
99
- INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
100
- )
101
- assert _decision(retry_write) is None
102
-
103
-
104
- def test_row_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
105
- package_directory = _package_directory(tmp_path)
106
- new_file = package_directory / "gamma.py"
107
- claude_md = package_directory / "CLAUDE.md"
108
-
109
- first_row = _run_hook(
110
- ORPHAN_HOOK,
111
- "Write",
112
- {"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
113
- tmp_path,
114
- )
115
- assert _decision(first_row) == "deny"
116
- assert "gamma.py" in _reason(first_row)
117
- assert "recorded" in _reason(first_row)
118
-
119
- file_write = _run_hook(
120
- INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
121
- )
122
- assert _decision(file_write) is None
123
-
124
- new_file.write_text(MODULE_BODY, encoding="utf-8")
125
- retry_row = _run_hook(
126
- ORPHAN_HOOK,
127
- "Write",
128
- {"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
129
- tmp_path,
130
- )
131
- assert _decision(retry_row) is None
132
-
133
-
134
- def test_new_file_with_no_row_attempt_stays_blocked(tmp_path: Path) -> None:
135
- package_directory = _package_directory(tmp_path)
136
- new_file = package_directory / "delta.py"
137
- file_write = _run_hook(
138
- INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
139
- )
140
- assert _decision(file_write) == "deny"
141
- assert "delta.py" in _reason(file_write)
142
-
143
-
144
- def test_row_naming_uncreated_file_stays_blocked(tmp_path: Path) -> None:
145
- package_directory = _package_directory(tmp_path)
146
- claude_md = package_directory / "CLAUDE.md"
147
- row_write = _run_hook(
148
- ORPHAN_HOOK,
149
- "Write",
150
- {"file_path": str(claude_md), "content": INVENTORY_WITH_EPSILON},
151
- tmp_path,
152
- )
153
- assert _decision(row_write) == "deny"
154
- assert "epsilon.py" in _reason(row_write)