claude-dev-env 8.25.0 → 8.26.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.
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -82
- package/hooks/blocking/code_rules_comments.py +14 -23
- package/hooks/blocking/code_rules_enforcer.py +0 -6
- package/hooks/blocking/code_rules_imports_logging.py +1 -120
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +9 -12
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +0 -10
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -16
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/blocking_check_limits.py +0 -13
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -8
- package/hooks/hooks_constants/local_identity.py +7 -120
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +0 -6
- package/hooks/hooks_constants/test_local_identity.py +7 -43
- package/hooks/hooks_constants/thread_spawn_pace_hook_constants.py +45 -0
- package/hooks/hooks_constants/usage_pace_constants.py +49 -0
- package/hooks/routing/conftest.py +10 -0
- package/hooks/routing/test_thread_spawn_pace_hook.py +139 -0
- package/hooks/routing/test_usage_pace.py +142 -0
- package/hooks/routing/thread_spawn_pace_hook.py +220 -0
- package/hooks/routing/usage_pace.py +345 -0
- package/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +0 -4
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/test_resolve_worker_spawn.py +0 -28
- package/docs/nas-ssh-invocation.md +0 -107
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +0 -266
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +0 -87
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +0 -134
- package/rules/nas-ssh-invocation.md +0 -29
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
"""Resolve local identity values for
|
|
1
|
+
"""Resolve local identity values for the PII hooks.
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
``
|
|
8
|
-
|
|
3
|
+
The hooks ship into ``~/.claude/`` and cannot read a repository file, so they
|
|
4
|
+
read machine values from the environment or a git-ignored file in the Claude
|
|
5
|
+
home directory. This module supplies the NAS host the PII scanner allowlists
|
|
6
|
+
and the owner/repo slug set used by ``pii_exempt_repository_slugs``
|
|
7
|
+
(``CLAUDE_PII_EXEMPT_REPOS`` / ``pii_exempt_repositories``). The committed NAS
|
|
8
|
+
host default is a placeholder.
|
|
9
9
|
|
|
10
10
|
::
|
|
11
11
|
|
|
12
12
|
CLAUDE_NAS_HOST set to a host -> nas_host() returns that host
|
|
13
|
-
CLAUDE_NAS_SSH_PORT set to 2200 -> nas_ssh_port() returns 2200
|
|
14
13
|
CLAUDE_PII_EXEMPT_REPOS="Owner/repo" -> {"owner/repo"}
|
|
15
14
|
(env unset, no file) -> nas_host() == "nas.example.local"
|
|
16
|
-
nas_ssh_port() == 22
|
|
17
15
|
pii_exempt_repository_slugs() == frozenset()
|
|
18
16
|
|
|
19
17
|
Each value comes from its environment variable, then
|
|
@@ -32,19 +30,11 @@ PII_EXEMPT_REPOS_ENV_VAR = "CLAUDE_PII_EXEMPT_REPOS"
|
|
|
32
30
|
PII_EXEMPT_REPOS_JSON_KEY = "pii_exempt_repositories"
|
|
33
31
|
PII_EXEMPT_REPOS_SEPARATOR = ","
|
|
34
32
|
PII_ALLOWLISTED_VALUES_JSON_KEY = "pii_allowlisted_values"
|
|
35
|
-
NAS_SSH_USER_ENV_VAR = "CLAUDE_NAS_SSH_USER"
|
|
36
|
-
NAS_SSH_PORT_ENV_VAR = "CLAUDE_NAS_SSH_PORT"
|
|
37
33
|
NAS_JSON_KEY = "nas"
|
|
38
34
|
NAS_HOST_JSON_KEY = "host"
|
|
39
|
-
NAS_SSH_USER_JSON_KEY = "ssh_user"
|
|
40
|
-
NAS_SSH_PORT_JSON_KEY = "ssh_port"
|
|
41
35
|
CLAUDE_HOME_DIRECTORY_NAME = ".claude"
|
|
42
36
|
LOCAL_IDENTITY_FILE_NAME = "local-identity.json"
|
|
43
37
|
PLACEHOLDER_NAS_HOST = "nas.example.local"
|
|
44
|
-
PLACEHOLDER_NAS_SSH_USER = "operator"
|
|
45
|
-
PLACEHOLDER_NAS_SSH_PORT = 22
|
|
46
|
-
OPENSSH_INVOCATION_EXAMPLE_BINARY = '"/c/Windows/System32/OpenSSH/ssh.exe"'
|
|
47
|
-
NAS_SSH_RULE_REFERENCE = "~/.claude/rules/nas-ssh-invocation.md"
|
|
48
38
|
|
|
49
39
|
|
|
50
40
|
def _local_identity_file_path() -> Path:
|
|
@@ -178,106 +168,3 @@ def nas_host() -> str:
|
|
|
178
168
|
if isinstance(stored_host, str) and stored_host:
|
|
179
169
|
return stored_host
|
|
180
170
|
return PLACEHOLDER_NAS_HOST
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def nas_ssh_user() -> str:
|
|
184
|
-
"""Return the ssh user the enforcer expects for the NAS.
|
|
185
|
-
|
|
186
|
-
::
|
|
187
|
-
|
|
188
|
-
CLAUDE_NAS_SSH_USER set to a user -> that user
|
|
189
|
-
(env unset, no file) -> "operator"
|
|
190
|
-
|
|
191
|
-
The environment variable wins, then the git-ignored file, then the
|
|
192
|
-
placeholder default.
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
The ssh login name for the NAS.
|
|
196
|
-
"""
|
|
197
|
-
user_from_environment = os.environ.get(NAS_SSH_USER_ENV_VAR)
|
|
198
|
-
if user_from_environment:
|
|
199
|
-
return user_from_environment
|
|
200
|
-
stored_user = _nas_section_from_local_file().get(NAS_SSH_USER_JSON_KEY)
|
|
201
|
-
if isinstance(stored_user, str) and stored_user:
|
|
202
|
-
return stored_user
|
|
203
|
-
return PLACEHOLDER_NAS_SSH_USER
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def nas_ssh_port() -> int:
|
|
207
|
-
"""Return the ssh port the enforcer expects for the NAS.
|
|
208
|
-
|
|
209
|
-
::
|
|
210
|
-
|
|
211
|
-
CLAUDE_NAS_SSH_PORT set to 2200 -> 2200
|
|
212
|
-
(env unset, no file) -> 22
|
|
213
|
-
|
|
214
|
-
The environment variable wins, then the git-ignored file, then the
|
|
215
|
-
placeholder default.
|
|
216
|
-
|
|
217
|
-
Returns:
|
|
218
|
-
The ssh port number for the NAS.
|
|
219
|
-
"""
|
|
220
|
-
port_from_environment = os.environ.get(NAS_SSH_PORT_ENV_VAR)
|
|
221
|
-
if port_from_environment and port_from_environment.isdigit():
|
|
222
|
-
return int(port_from_environment)
|
|
223
|
-
stored_port = _nas_section_from_local_file().get(NAS_SSH_PORT_JSON_KEY)
|
|
224
|
-
if isinstance(stored_port, int):
|
|
225
|
-
return stored_port
|
|
226
|
-
return PLACEHOLDER_NAS_SSH_PORT
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def _openssh_invocation_example() -> str:
|
|
230
|
-
return (
|
|
231
|
-
f"{OPENSSH_INVOCATION_EXAMPLE_BINARY} -o BatchMode=yes -o ConnectTimeout=10 "
|
|
232
|
-
f'-p {nas_ssh_port()} {nas_ssh_user()}@{nas_host()} "<cmd>"'
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def bare_ssh_binary_deny_message() -> str:
|
|
237
|
-
"""Return the deny message for a bare ssh-family word aimed at the NAS.
|
|
238
|
-
|
|
239
|
-
::
|
|
240
|
-
|
|
241
|
-
ssh -p 22 operator@nas.example.local "ls" -> this message text
|
|
242
|
-
(the text quotes the resolved host, user, and port)
|
|
243
|
-
|
|
244
|
-
Names the Git Bash MSYS-ssh hang and points at the Windows OpenSSH binary
|
|
245
|
-
with batch mode on.
|
|
246
|
-
|
|
247
|
-
Returns:
|
|
248
|
-
The full deny-message text for the bare ssh-family case.
|
|
249
|
-
"""
|
|
250
|
-
return (
|
|
251
|
-
f"BLOCKED [nas-ssh-binary]: Git Bash's MSYS ssh reads ~/.ssh/id_ed25519 as "
|
|
252
|
-
f"world-readable through its ACL mapping, rejects the key as bad permissions, "
|
|
253
|
-
f"and falls back to an interactive password prompt that hangs unattended "
|
|
254
|
-
f"sessions against the NAS at {nas_host()}.\n\n"
|
|
255
|
-
f"Use the Windows OpenSSH binary, which authenticates the key without prompting:\n"
|
|
256
|
-
f" {_openssh_invocation_example()}\n\n"
|
|
257
|
-
f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def missing_batch_mode_deny_message() -> str:
|
|
262
|
-
"""Return the deny message for the OpenSSH binary without batch mode.
|
|
263
|
-
|
|
264
|
-
::
|
|
265
|
-
|
|
266
|
-
"..ssh.exe" -p 22 operator@nas.example.local "ls" -> this message text
|
|
267
|
-
(the text quotes the resolved host, user, and port)
|
|
268
|
-
|
|
269
|
-
Names the interactive-prompt hang and asks for ``-o BatchMode=yes`` so a key
|
|
270
|
-
failure exits loudly.
|
|
271
|
-
|
|
272
|
-
Returns:
|
|
273
|
-
The full deny-message text for the missing-batch-mode case.
|
|
274
|
-
"""
|
|
275
|
-
return (
|
|
276
|
-
f"BLOCKED [nas-ssh-binary]: this NAS ssh command uses the Windows OpenSSH binary "
|
|
277
|
-
f"but omits -o BatchMode=yes, so an authentication regression falls back to an "
|
|
278
|
-
f"interactive password prompt that hangs unattended sessions against the NAS at "
|
|
279
|
-
f"{nas_host()}.\n\n"
|
|
280
|
-
f"Add -o BatchMode=yes so a key failure exits loudly rather than prompting:\n"
|
|
281
|
-
f" {_openssh_invocation_example()}\n\n"
|
|
282
|
-
f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
|
|
283
|
-
)
|
|
@@ -17,7 +17,6 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
17
17
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
18
18
|
|
|
19
19
|
from hooks_constants.code_rules_enforcer_constants import (
|
|
20
|
-
ALL_FREE_FORM_EXEMPT_COMMENT_BODIES,
|
|
21
20
|
ALL_VALIDATION_PHASES,
|
|
22
21
|
CONFIG_DIRECTORY_SEGMENT,
|
|
23
22
|
CONSTANTS_MODULE_SUFFIX,
|
|
@@ -27,7 +26,6 @@ from hooks_constants.code_rules_enforcer_constants import (
|
|
|
27
26
|
JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
|
|
28
27
|
RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
|
|
29
28
|
SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
|
|
30
|
-
STEALTH_KEEP_COMMENT_MARKER,
|
|
31
29
|
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
|
|
32
30
|
apply_edits,
|
|
33
31
|
edits_for_tool,
|
|
@@ -108,10 +106,6 @@ def test_constants_module_suffix_and_config_directory_segment_are_defined() -> N
|
|
|
108
106
|
assert CONFIG_DIRECTORY_SEGMENT == "config"
|
|
109
107
|
|
|
110
108
|
|
|
111
|
-
def test_free_form_exempt_comment_bodies_include_stealth_keep_marker() -> None:
|
|
112
|
-
assert STEALTH_KEEP_COMMENT_MARKER in ALL_FREE_FORM_EXEMPT_COMMENT_BODIES
|
|
113
|
-
|
|
114
|
-
|
|
115
109
|
def test_validation_phase_constants_are_reexported_and_render() -> None:
|
|
116
110
|
assert ALL_VALIDATION_PHASES == {EDIT_LANE_PHASE, FULL_GATE_PHASE}
|
|
117
111
|
rendered_message = UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE.format(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for the
|
|
1
|
+
"""Tests for the local-identity loader used by the PII hooks."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -14,7 +14,7 @@ except ImportError:
|
|
|
14
14
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
15
15
|
from hooks_constants import local_identity
|
|
16
16
|
|
|
17
|
-
ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST",
|
|
17
|
+
ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST",)
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _clear_nas_env() -> None:
|
|
@@ -23,30 +23,20 @@ def _clear_nas_env() -> None:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class TestNasValuesFromEnvironment:
|
|
26
|
-
def
|
|
27
|
-
with patch.dict(
|
|
28
|
-
os.environ,
|
|
29
|
-
{
|
|
30
|
-
"CLAUDE_NAS_HOST": "10.0.0.5",
|
|
31
|
-
"CLAUDE_NAS_SSH_USER": "tester",
|
|
32
|
-
"CLAUDE_NAS_SSH_PORT": "2200",
|
|
33
|
-
},
|
|
34
|
-
clear=False,
|
|
35
|
-
):
|
|
26
|
+
def should_read_host_from_the_environment(self) -> None:
|
|
27
|
+
with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
|
|
36
28
|
assert local_identity.nas_host() == "10.0.0.5"
|
|
37
|
-
assert local_identity.nas_ssh_user() == "tester"
|
|
38
|
-
assert local_identity.nas_ssh_port() == 2200
|
|
39
29
|
|
|
40
30
|
|
|
41
31
|
class TestNasValuesFromLocalFile:
|
|
42
|
-
def
|
|
32
|
+
def should_read_host_from_the_local_identity_file(
|
|
43
33
|
self, tmp_path: Path
|
|
44
34
|
) -> None:
|
|
45
35
|
claude_home = tmp_path / ".claude"
|
|
46
36
|
claude_home.mkdir()
|
|
47
37
|
(claude_home / "local-identity.json").write_text(
|
|
48
38
|
json.dumps(
|
|
49
|
-
{"nas": {"host": "10.1.1.9"
|
|
39
|
+
{"nas": {"host": "10.1.1.9"}}
|
|
50
40
|
),
|
|
51
41
|
encoding="utf-8",
|
|
52
42
|
)
|
|
@@ -54,18 +44,14 @@ class TestNasValuesFromLocalFile:
|
|
|
54
44
|
_clear_nas_env()
|
|
55
45
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
56
46
|
assert local_identity.nas_host() == "10.1.1.9"
|
|
57
|
-
assert local_identity.nas_ssh_user() == "fileuser"
|
|
58
|
-
assert local_identity.nas_ssh_port() == 2222
|
|
59
47
|
|
|
60
48
|
|
|
61
49
|
class TestNasValuesPlaceholderDefault:
|
|
62
|
-
def
|
|
50
|
+
def should_return_placeholder_when_no_env_and_no_file(self, tmp_path: Path) -> None:
|
|
63
51
|
with patch.dict(os.environ, {}, clear=False):
|
|
64
52
|
_clear_nas_env()
|
|
65
53
|
with patch.object(Path, "home", return_value=tmp_path):
|
|
66
54
|
assert local_identity.nas_host() == "nas.example.local"
|
|
67
|
-
assert local_identity.nas_ssh_user() == "operator"
|
|
68
|
-
assert local_identity.nas_ssh_port() == 22
|
|
69
55
|
|
|
70
56
|
|
|
71
57
|
class TestPiiExemptRepositorySlugs:
|
|
@@ -234,25 +220,3 @@ class TestLocalIdentityPathOverride:
|
|
|
234
220
|
), patch.object(Path, "home", return_value=tmp_path):
|
|
235
221
|
_clear_nas_env()
|
|
236
222
|
assert local_identity.nas_host() == "10.9.9.9"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
class TestDenyMessagesQuoteTheResolvedHost:
|
|
240
|
-
def should_include_the_resolved_host_and_port_in_the_bare_binary_message(
|
|
241
|
-
self,
|
|
242
|
-
) -> None:
|
|
243
|
-
with patch.dict(
|
|
244
|
-
os.environ,
|
|
245
|
-
{"CLAUDE_NAS_HOST": "10.0.0.5", "CLAUDE_NAS_SSH_PORT": "2200"},
|
|
246
|
-
clear=False,
|
|
247
|
-
):
|
|
248
|
-
os.environ.pop("CLAUDE_NAS_SSH_USER", None)
|
|
249
|
-
message = local_identity.bare_ssh_binary_deny_message()
|
|
250
|
-
assert "10.0.0.5" in message
|
|
251
|
-
assert "-p 2200" in message
|
|
252
|
-
assert "BatchMode=yes" in message
|
|
253
|
-
|
|
254
|
-
def should_include_the_resolved_host_in_the_missing_batch_mode_message(self) -> None:
|
|
255
|
-
with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
|
|
256
|
-
message = local_identity.missing_batch_mode_deny_message()
|
|
257
|
-
assert "10.0.0.5" in message
|
|
258
|
-
assert "BatchMode=yes" in message
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Constants for the thread spawn pace PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Groups: the usage-pace script it runs, the reshape values for an over-pace
|
|
4
|
+
spawn, and the hook output keys and messages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
USAGE_PACE_SCRIPT_ENV_VAR = "COORDINATOR_USAGE_PACE_SCRIPT"
|
|
10
|
+
USAGE_PACE_SCRIPT_FILE_NAME = "usage_pace.py"
|
|
11
|
+
USAGE_PACE_TIMEOUT_SECONDS = 15
|
|
12
|
+
|
|
13
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
14
|
+
THREAD_MODEL_WHEN_OVER_PACE = "claude-sonnet-5-5"
|
|
15
|
+
THREAD_EFFORT_WHEN_OVER_PACE = "medium"
|
|
16
|
+
MODEL_INPUT_KEY = "model"
|
|
17
|
+
EFFORT_INPUT_KEY = "effort"
|
|
18
|
+
INSTRUCTIONS_INPUT_KEY = "instructions"
|
|
19
|
+
INSTRUCTIONS_MAXIMUM_BYTES = 8192
|
|
20
|
+
INSTRUCTIONS_SEPARATOR = "\n\n"
|
|
21
|
+
INSTRUCTIONS_ENCODING = "utf-8"
|
|
22
|
+
FABLE_ADVISOR_LINE = (
|
|
23
|
+
"Mandatory Fable advisor: usage is over pace, so this thread runs on "
|
|
24
|
+
"Sonnet 5.5 at medium effort. Invoke /team-advisor before substantive "
|
|
25
|
+
"work and bind a Fable advisor through it. Consult that advisor again "
|
|
26
|
+
"before you report done, and name its verdict in your report."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PERMISSION_DENY = "deny"
|
|
30
|
+
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
31
|
+
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
32
|
+
NOT_AN_OBJECT_REASON = "start_thread_session input is not a JSON object"
|
|
33
|
+
NO_INSTRUCTIONS_REASON = "start_thread_session input has no instructions text"
|
|
34
|
+
OVER_BYTE_CAP_REASON_TEMPLATE = (
|
|
35
|
+
"usage is over pace and the brief plus the mandatory Fable advisor line is "
|
|
36
|
+
"{reshaped_byte_count} bytes, over the {maximum_bytes}-byte cap; "
|
|
37
|
+
"shorten the instructions by {excess_byte_count} bytes"
|
|
38
|
+
)
|
|
39
|
+
RESHAPE_CONTEXT_PREFIX = (
|
|
40
|
+
"Usage over pace or unreadable: this thread spawn now runs on "
|
|
41
|
+
"claude-sonnet-5-5 at medium effort with a mandatory Fable advisor line. "
|
|
42
|
+
"Pace verdict: "
|
|
43
|
+
)
|
|
44
|
+
PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS = 600
|
|
45
|
+
LAUNCH_FAILURE_ERROR_TEMPLATE = "usage pace did not finish: {failure_type}"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Constants for the usage-pace reader that the thread spawn pace hook runs.
|
|
2
|
+
|
|
3
|
+
Groups: the OAuth usage endpoint probe, the token and certificate sources,
|
|
4
|
+
the rate windows and their lengths, the exit codes, and the JSON keys of the
|
|
5
|
+
verdict.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
|
|
12
|
+
USAGE_ENDPOINT_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
13
|
+
ANTHROPIC_BETA_HEADER = "anthropic-beta"
|
|
14
|
+
USAGE_BETA_VALUE = "oauth-2025-04-20"
|
|
15
|
+
AUTHORIZATION_HEADER = "Authorization"
|
|
16
|
+
BEARER_PREFIX = "Bearer "
|
|
17
|
+
CONTENT_TYPE_HEADER_NAME = "Content-Type"
|
|
18
|
+
CONTENT_TYPE_JSON = "application/json"
|
|
19
|
+
PROBE_TIMEOUT_SECONDS = 8
|
|
20
|
+
|
|
21
|
+
INGRESS_BEARER_FILE_ENV_VAR = "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
|
|
22
|
+
SSL_CERT_FILE_ENV_VAR = "SSL_CERT_FILE"
|
|
23
|
+
CONTAINER_PROXY_CA_BUNDLE_PATH = "/root/.ccr/ca-bundle.crt"
|
|
24
|
+
|
|
25
|
+
UTILIZATION_KEY = "utilization"
|
|
26
|
+
RESETS_AT_KEY = "resets_at"
|
|
27
|
+
ISO_UTC_SUFFIX = "Z"
|
|
28
|
+
ISO_UTC_OFFSET = "+00:00"
|
|
29
|
+
ALL_RATE_WINDOW_LENGTHS = (
|
|
30
|
+
("five_hour", timedelta(hours=5)),
|
|
31
|
+
("seven_day", timedelta(days=7)),
|
|
32
|
+
)
|
|
33
|
+
PERCENT_SCALE = 100.0
|
|
34
|
+
PERCENT_DECIMAL_PLACES = 1
|
|
35
|
+
MINIMUM_ELAPSED_PERCENT_FOR_PACE = 10.0
|
|
36
|
+
|
|
37
|
+
EXIT_CODE_OVER_PACE = 0
|
|
38
|
+
EXIT_CODE_UNDER_PACE = 1
|
|
39
|
+
EXIT_CODE_UNREADABLE = 2
|
|
40
|
+
|
|
41
|
+
RESULT_KEY_OVER_PACE = "over_pace"
|
|
42
|
+
RESULT_KEY_CHECKED_AT = "checked_at"
|
|
43
|
+
RESULT_KEY_WINDOWS = "windows"
|
|
44
|
+
RESULT_KEY_ERROR = "error"
|
|
45
|
+
WINDOW_KEY_NAME = "window"
|
|
46
|
+
WINDOW_KEY_USED_PERCENT = "used_percent"
|
|
47
|
+
WINDOW_KEY_ELAPSED_PERCENT = "elapsed_percent"
|
|
48
|
+
WINDOW_KEY_RESETS_AT = "resets_at"
|
|
49
|
+
WINDOW_KEY_OVER_PACE = "over_pace"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Add hooks/routing and hooks/ to sys.path for every test collected under this directory."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_ROUTING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
7
|
+
_HOOKS_DIRECTORY = str(Path(_ROUTING_DIRECTORY).parent)
|
|
8
|
+
for each_directory in (_ROUTING_DIRECTORY, _HOOKS_DIRECTORY):
|
|
9
|
+
if each_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, each_directory)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from hooks_constants.thread_spawn_pace_hook_constants import FABLE_ADVISOR_LINE
|
|
9
|
+
|
|
10
|
+
HOOK_SCRIPT = Path(__file__).resolve().parent / "thread_spawn_pace_hook.py"
|
|
11
|
+
SPAWN_INPUT = {
|
|
12
|
+
"thread_id": "cmsg_example",
|
|
13
|
+
"title": "Example thread",
|
|
14
|
+
"ack": "Starting on the example first.",
|
|
15
|
+
"instructions": "Do the example task.",
|
|
16
|
+
"context_message_ids": ["cmsg_example"],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_pace_stub(
|
|
21
|
+
tmp_path: Path, exit_code: int, verdict: dict[str, object]
|
|
22
|
+
) -> Path:
|
|
23
|
+
stub_path = tmp_path / f"pace_stub_{exit_code}.py"
|
|
24
|
+
stub_path.write_text(
|
|
25
|
+
f"import sys\nprint({json.dumps(json.dumps(verdict))})\nsys.exit({exit_code})\n",
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
return stub_path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run_hook(pace_script: Path, tool_input: object) -> str:
|
|
32
|
+
hook_payload = {
|
|
33
|
+
"hook_event_name": "PreToolUse",
|
|
34
|
+
"tool_name": "mcp__hearthbot__start_thread_session",
|
|
35
|
+
"tool_input": tool_input,
|
|
36
|
+
}
|
|
37
|
+
completed = subprocess.run(
|
|
38
|
+
[sys.executable, str(HOOK_SCRIPT)],
|
|
39
|
+
input=json.dumps(hook_payload),
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
env={**os.environ, "COORDINATOR_USAGE_PACE_SCRIPT": str(pace_script)},
|
|
43
|
+
check=True,
|
|
44
|
+
)
|
|
45
|
+
return completed.stdout
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _hook_specific_output(stdout: str) -> dict[str, object]:
|
|
49
|
+
return json.loads(stdout)["hookSpecificOutput"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _assert_reshaped(stdout: str) -> None:
|
|
53
|
+
decision = _hook_specific_output(stdout)
|
|
54
|
+
assert decision["hookEventName"] == "PreToolUse"
|
|
55
|
+
assert decision["permissionDecision"] == "allow"
|
|
56
|
+
assert decision["updatedInput"] == {
|
|
57
|
+
**SPAWN_INPUT,
|
|
58
|
+
"model": "claude-sonnet-5-5",
|
|
59
|
+
"effort": "medium",
|
|
60
|
+
"instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_should_reshape_the_spawn_when_usage_is_over_pace(tmp_path: Path) -> None:
|
|
65
|
+
_assert_reshaped(
|
|
66
|
+
_run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), SPAWN_INPUT)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_pass_the_spawn_through_unchanged_when_usage_is_under_pace(
|
|
71
|
+
tmp_path: Path,
|
|
72
|
+
) -> None:
|
|
73
|
+
assert (
|
|
74
|
+
_run_hook(_write_pace_stub(tmp_path, 1, {"over_pace": False}), SPAWN_INPUT)
|
|
75
|
+
== ""
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_should_reshape_the_spawn_when_usage_is_unreadable(tmp_path: Path) -> None:
|
|
80
|
+
_assert_reshaped(
|
|
81
|
+
_run_hook(
|
|
82
|
+
_write_pace_stub(tmp_path, 2, {"over_pace": None, "error": "x"}),
|
|
83
|
+
SPAWN_INPUT,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_should_reshape_the_spawn_when_the_pace_script_is_missing(
|
|
89
|
+
tmp_path: Path,
|
|
90
|
+
) -> None:
|
|
91
|
+
_assert_reshaped(_run_hook(tmp_path / "absent.py", SPAWN_INPUT))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_should_append_the_advisor_line_once(tmp_path: Path) -> None:
|
|
95
|
+
already_reshaped = {
|
|
96
|
+
**SPAWN_INPUT,
|
|
97
|
+
"instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
|
|
98
|
+
}
|
|
99
|
+
stdout = _run_hook(
|
|
100
|
+
_write_pace_stub(tmp_path, 0, {"over_pace": True}), already_reshaped
|
|
101
|
+
)
|
|
102
|
+
assert (
|
|
103
|
+
_hook_specific_output(stdout)["updatedInput"]["instructions"].count(
|
|
104
|
+
FABLE_ADVISOR_LINE
|
|
105
|
+
)
|
|
106
|
+
== 1
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize(
|
|
111
|
+
("tool_input", "reason"),
|
|
112
|
+
[
|
|
113
|
+
(["not", "an", "object"], "start_thread_session input is not a JSON object"),
|
|
114
|
+
({"title": "No brief"}, "start_thread_session input has no instructions text"),
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
def test_should_deny_a_spawn_it_cannot_reshape(
|
|
118
|
+
tmp_path: Path, tool_input: object, reason: str
|
|
119
|
+
) -> None:
|
|
120
|
+
decision = _hook_specific_output(
|
|
121
|
+
_run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), tool_input)
|
|
122
|
+
)
|
|
123
|
+
assert decision["permissionDecision"] == "deny"
|
|
124
|
+
assert decision["permissionDecisionReason"] == reason
|
|
125
|
+
assert "updatedInput" not in decision
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_should_deny_a_brief_the_advisor_line_would_push_past_the_byte_cap(
|
|
129
|
+
tmp_path: Path,
|
|
130
|
+
) -> None:
|
|
131
|
+
full_brief = {**SPAWN_INPUT, "instructions": "x" * 8100}
|
|
132
|
+
decision = _hook_specific_output(
|
|
133
|
+
_run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), full_brief)
|
|
134
|
+
)
|
|
135
|
+
assert decision["permissionDecision"] == "deny"
|
|
136
|
+
over_by = 8100 + 2 + len(FABLE_ADVISOR_LINE.encode("utf-8")) - 8192
|
|
137
|
+
assert decision["permissionDecisionReason"].endswith(
|
|
138
|
+
f"shorten the instructions by {over_by} bytes"
|
|
139
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from usage_pace import (
|
|
9
|
+
UsageUnreadable,
|
|
10
|
+
build_verdict,
|
|
11
|
+
measure_account_pace,
|
|
12
|
+
measure_window_pace,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
USAGE_PACE_SCRIPT = Path(__file__).resolve().parent / "usage_pace.py"
|
|
16
|
+
NOW = datetime(2026, 9, 29, 13, 57, 40, tzinfo=timezone.utc)
|
|
17
|
+
FIVE_HOUR_RESET = "2026-09-29T18:50:00+00:00"
|
|
18
|
+
WEEKLY_RESET = "2026-10-03T20:00:00+00:00"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _payload(five_hour_used: float, weekly_used: float) -> dict[str, object]:
|
|
22
|
+
return {
|
|
23
|
+
"five_hour": {"utilization": five_hour_used, "resets_at": FIVE_HOUR_RESET},
|
|
24
|
+
"seven_day": {"utilization": weekly_used, "resets_at": WEEKLY_RESET},
|
|
25
|
+
"seven_day_opus": None,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run_script(tmp_path: Path, payload: object) -> tuple[int, dict[str, object]]:
|
|
30
|
+
payload_path = tmp_path / "usage.json"
|
|
31
|
+
payload_path.write_text(json.dumps(payload), encoding="utf-8")
|
|
32
|
+
completed = subprocess.run(
|
|
33
|
+
[
|
|
34
|
+
sys.executable,
|
|
35
|
+
str(USAGE_PACE_SCRIPT),
|
|
36
|
+
"--payload-file",
|
|
37
|
+
str(payload_path),
|
|
38
|
+
"--now",
|
|
39
|
+
NOW.isoformat(),
|
|
40
|
+
],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
return completed.returncode, json.loads(completed.stdout)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_should_hold_a_window_clear_before_ten_percent_of_it_has_elapsed() -> None:
|
|
49
|
+
pace = measure_window_pace(
|
|
50
|
+
"five_hour",
|
|
51
|
+
timedelta(hours=5),
|
|
52
|
+
{"utilization": 3.0, "resets_at": FIVE_HOUR_RESET},
|
|
53
|
+
NOW,
|
|
54
|
+
)
|
|
55
|
+
assert pace.elapsed_percent == 2.6
|
|
56
|
+
assert pace.over_pace is False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_should_flag_a_window_whose_spend_runs_ahead_of_the_clock_past_the_floor() -> (
|
|
60
|
+
None
|
|
61
|
+
):
|
|
62
|
+
pace = measure_window_pace(
|
|
63
|
+
"five_hour",
|
|
64
|
+
timedelta(hours=5),
|
|
65
|
+
{"utilization": 50.0, "resets_at": "2026-09-29T17:00:00+00:00"},
|
|
66
|
+
NOW,
|
|
67
|
+
)
|
|
68
|
+
assert pace.elapsed_percent == 39.2
|
|
69
|
+
assert pace.over_pace is True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_should_clear_a_window_whose_spend_trails_the_clock() -> None:
|
|
73
|
+
pace = measure_window_pace(
|
|
74
|
+
"seven_day",
|
|
75
|
+
timedelta(days=7),
|
|
76
|
+
{"utilization": 20.0, "resets_at": WEEKLY_RESET},
|
|
77
|
+
NOW,
|
|
78
|
+
)
|
|
79
|
+
assert pace.elapsed_percent == 39.3
|
|
80
|
+
assert pace.over_pace is False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_should_clamp_elapsed_time_to_the_window_length() -> None:
|
|
84
|
+
long_past_reset = measure_window_pace(
|
|
85
|
+
"five_hour",
|
|
86
|
+
timedelta(hours=5),
|
|
87
|
+
{"utilization": 99.0, "resets_at": "2026-09-29T10:00:00Z"},
|
|
88
|
+
NOW,
|
|
89
|
+
)
|
|
90
|
+
assert long_past_reset.elapsed_percent == 100.0
|
|
91
|
+
assert long_past_reset.over_pace is False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_should_report_over_pace_when_any_window_runs_ahead() -> None:
|
|
95
|
+
verdict = build_verdict(measure_account_pace(_payload(1.0, 93.0), NOW), NOW)
|
|
96
|
+
assert verdict["over_pace"] is True
|
|
97
|
+
assert [each["over_pace"] for each in verdict["windows"]] == [False, True]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_should_report_under_pace_when_every_window_trails() -> None:
|
|
101
|
+
verdict = build_verdict(measure_account_pace(_payload(1.0, 20.0), NOW), NOW)
|
|
102
|
+
assert verdict["over_pace"] is False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize(
|
|
106
|
+
"broken_payload",
|
|
107
|
+
[
|
|
108
|
+
{"seven_day": {"utilization": 20.0, "resets_at": WEEKLY_RESET}},
|
|
109
|
+
{
|
|
110
|
+
"five_hour": {"utilization": None, "resets_at": FIVE_HOUR_RESET},
|
|
111
|
+
"seven_day": {"utilization": 1.0, "resets_at": WEEKLY_RESET},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"five_hour": {"utilization": 1.0, "resets_at": "soon"},
|
|
115
|
+
"seven_day": {"utilization": 1.0, "resets_at": WEEKLY_RESET},
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
def test_should_treat_a_missing_or_unreadable_window_as_unreadable(
|
|
120
|
+
broken_payload: dict[str, object],
|
|
121
|
+
) -> None:
|
|
122
|
+
with pytest.raises(UsageUnreadable):
|
|
123
|
+
measure_account_pace(broken_payload, NOW)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_should_exit_zero_with_the_verdict_when_over_pace(tmp_path: Path) -> None:
|
|
127
|
+
exit_code, verdict = _run_script(tmp_path, _payload(3.0, 93.0))
|
|
128
|
+
assert exit_code == 0
|
|
129
|
+
assert verdict["over_pace"] is True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_should_exit_one_when_under_pace(tmp_path: Path) -> None:
|
|
133
|
+
exit_code, verdict = _run_script(tmp_path, _payload(1.0, 20.0))
|
|
134
|
+
assert exit_code == 1
|
|
135
|
+
assert verdict["over_pace"] is False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_should_exit_two_with_a_null_verdict_when_unreadable(tmp_path: Path) -> None:
|
|
139
|
+
exit_code, verdict = _run_script(tmp_path, {"five_hour": None})
|
|
140
|
+
assert exit_code == 2
|
|
141
|
+
assert verdict["over_pace"] is None
|
|
142
|
+
assert verdict["error"] == "usage response has no five_hour window"
|