claude-dev-env 8.73.1 → 8.73.2
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/.agents/agents/test_agent_frontmatter.py +23 -23
- package/.agents/agents-archived/clean-coder.md +5 -5
- package/.agents/skills/correction/playbooks/fix.md +1 -1
- package/.agents/skills/eli5/SKILL.md +3 -3
- package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/SKILL.md +1 -1
- package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
- package/_shared/advisor/reference/third-party-bind.md +1 -1
- package/bin/install.mjs +1 -0
- package/bin/install.policy-lint.test.mjs +1 -0
- package/bin/install.profile-root.test.mjs +1 -1
- package/bin/install.test.mjs +1 -0
- package/commands/fix-followups.md +1 -1
- package/commands/sr-loop.md +1 -1
- package/docs/references/prose-style-enforcement.md +1 -1
- package/docs/references/working-style.md +1 -1
- package/docs/rule-guides/asd-ste100-language.md +9 -1
- package/docs/rule-guides/cleanup-temp-files.md +8 -2
- package/docs/rule-guides/destructive-commands.md +13 -3
- package/docs/rule-guides/explore-thoroughly.md +9 -1
- package/docs/rule-guides/features-start-with-an-eval.md +8 -2
- package/docs/rule-guides/filesystem-search.md +7 -1
- package/docs/rule-guides/long-horizon-autonomy.md +1 -1
- package/docs/rule-guides/memory-stores-durable-facts.md +9 -3
- package/docs/rule-guides/no-contrast-framing.md +10 -4
- package/docs/rule-guides/prefer-existing-tools.md +2 -2
- package/docs/rule-guides/proof-before-pull-request.md +5 -1
- package/docs/rule-guides/request-scope.md +5 -1
- package/docs/rule-guides/research-mode.md +9 -1
- package/docs/rule-guides/session-title.md +7 -1
- package/docs/rule-guides/shell-invocation.md +9 -1
- package/docs/rule-guides/verify-before-asking.md +7 -1
- package/docs/rule-guides/verify-runtime-state.md +7 -1
- package/hooks/blocking/code_rules_enforcer.py +16 -6
- package/hooks/blocking/code_rules_shared.py +23 -4
- package/hooks/blocking/reply_length_gate.py +74 -14
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +54 -1
- package/hooks/blocking/test_code_rules_shared.py +33 -0
- package/hooks/blocking/test_reply_length_gate.py +184 -5
- package/hooks/blocking/test_state_description_blocker.py +1 -1
- package/hooks/features/conduct-gates.md +3 -6
- package/hooks/hooks.json +1 -6
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +1 -0
- package/hooks/hooks_constants/session_env_cleanup_constants.py +0 -2
- package/hooks/hooks_constants/test_visual_reply_rules_constants.py +15 -0
- package/hooks/hooks_constants/test_windows_filesystem.py +57 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +63 -0
- package/hooks/hooks_constants/windows_filesystem.py +51 -0
- package/hooks/session/session_env_cleanup.py +4 -30
- package/hooks/test_visual_reply_rules.py +164 -0
- package/hooks/visual_reply_rules.py +268 -0
- package/hooks/workflow/test_auto_formatter.py +4 -25
- package/package.json +1 -1
- package/rules/index.md +30 -0
- package/rules/visual-reply-rules.json +41 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/conftest.py +13 -5
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +2 -2
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +12 -0
- package/scripts/policy_lint/adapter_configuration.py +0 -5
- package/scripts/policy_lint/config/constants.py +0 -1
- package/scripts/test_active_capability_references.py +0 -5
- package/scripts/test_broker_state_isolation.py +3 -3
- package/scripts/test_digest_check.py +0 -5
- package/scripts/test_durable_post_lint.py +1 -5
- package/scripts/test_followup_cli.py +0 -6
- package/scripts/test_resolve_worker_spawn.py +5 -10
- package/scripts/test_setup_project_paths.py +0 -12
- package/scripts/test_shared_tree_paths.py +2 -6
- package/scripts/test_validate_instruction_pairs.py +0 -7
- package/scripts/test_verify_installable_package.py +0 -5
- package/scripts/tests/test_contrast_framing.py +3 -3
- package/scripts/tests/test_rule_index_shape.py +59 -5
- package/scripts/tests/test_rule_load_scopes.py +1 -16
- package/system-prompts/software-engineer.xml +4 -4
- package/hooks/blocking/artifact_look_gate.py +0 -223
- package/hooks/blocking/test_artifact_look_gate.py +0 -153
- package/hooks/hooks_constants/artifact_look_gate_constants.py +0 -32
- package/rules/asd-ste100-language.md +0 -9
- package/rules/cleanup-temp-files.md +0 -9
- package/rules/destructive-commands.md +0 -11
- package/rules/explore-thoroughly.md +0 -9
- package/rules/features-start-with-an-eval.md +0 -9
- package/rules/filesystem-search.md +0 -9
- package/rules/memory-stores-durable-facts.md +0 -9
- package/rules/no-contrast-framing.md +0 -9
- package/rules/proof-before-pull-request.md +0 -7
- package/rules/request-scope.md +0 -7
- package/rules/research-mode.md +0 -9
- package/rules/session-title.md +0 -9
- package/rules/shell-invocation.md +0 -9
- package/rules/skill-pointers.md +0 -3
- package/rules/verify-before-asking.md +0 -9
- package/rules/verify-runtime-state.md +0 -9
|
@@ -118,9 +118,10 @@ from code_rules_paths_syspath import ( # noqa: E402
|
|
|
118
118
|
check_hardcoded_user_paths,
|
|
119
119
|
check_sys_path_insert_deduplication_guard,
|
|
120
120
|
)
|
|
121
|
-
from code_rules_shared import (
|
|
121
|
+
from code_rules_shared import (
|
|
122
122
|
changed_line_numbers,
|
|
123
123
|
get_file_extension,
|
|
124
|
+
is_agent_home_tooling_outside_working_directory,
|
|
124
125
|
is_ephemeral_script_path,
|
|
125
126
|
is_hook_infrastructure,
|
|
126
127
|
is_test_file,
|
|
@@ -754,6 +755,13 @@ def prior_and_post_edit_content(
|
|
|
754
755
|
return existing_content, apply_edits(existing_content, [one_edit])
|
|
755
756
|
|
|
756
757
|
|
|
758
|
+
def _is_exempt_tooling(file_path: str, repository_root: str) -> bool:
|
|
759
|
+
"""Return whether the path is hook infrastructure or agent-home tooling outside the cwd."""
|
|
760
|
+
return is_hook_infrastructure(file_path) or is_agent_home_tooling_outside_working_directory(
|
|
761
|
+
file_path, repository_root
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
757
765
|
def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
|
|
758
766
|
"""Return whether the path is subject to code-rules validation.
|
|
759
767
|
|
|
@@ -764,14 +772,15 @@ def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
|
|
|
764
772
|
scratch merely because the repository sits under ``/tmp``.
|
|
765
773
|
|
|
766
774
|
Returns:
|
|
767
|
-
|
|
768
|
-
|
|
775
|
+
Whether the path is non-empty, outside hook infrastructure, outside
|
|
776
|
+
agent-home tooling that sits beyond ``repository_root``, and carries a
|
|
777
|
+
code extension.
|
|
769
778
|
"""
|
|
770
779
|
if not file_path:
|
|
771
780
|
return False
|
|
772
781
|
if is_ephemeral_script_path(file_path, repository_root):
|
|
773
782
|
return False
|
|
774
|
-
if
|
|
783
|
+
if _is_exempt_tooling(file_path, repository_root):
|
|
775
784
|
return False
|
|
776
785
|
return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
|
|
777
786
|
|
|
@@ -791,13 +800,14 @@ def _is_hook_infrastructure_python_target(file_path: str, repository_root: str =
|
|
|
791
800
|
scratch merely because the repository sits under ``/tmp``.
|
|
792
801
|
|
|
793
802
|
Returns:
|
|
794
|
-
|
|
803
|
+
Whether the path names a Python file inside hook infrastructure or
|
|
804
|
+
inside agent-home tooling that sits beyond ``repository_root``.
|
|
795
805
|
"""
|
|
796
806
|
if not file_path:
|
|
797
807
|
return False
|
|
798
808
|
if is_ephemeral_script_path(file_path, repository_root):
|
|
799
809
|
return False
|
|
800
|
-
if not
|
|
810
|
+
if not _is_exempt_tooling(file_path, repository_root):
|
|
801
811
|
return False
|
|
802
812
|
return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
803
813
|
|
|
@@ -69,6 +69,27 @@ def is_agent_home_tooling(file_path: str) -> bool:
|
|
|
69
69
|
return any(each_pattern in path_lower for each_pattern in ALL_AGENT_HOME_TOOLING_PATTERNS)
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def is_agent_home_tooling_outside_working_directory(file_path: str, repository_root: str) -> bool:
|
|
73
|
+
"""Check whether a path is agent-home tooling that sits outside the session cwd.
|
|
74
|
+
|
|
75
|
+
A repository checked out under an agent home, such as
|
|
76
|
+
``~/.grok/runs/<repo>/``, is project code whenever the session works inside
|
|
77
|
+
it, so only agent-home paths outside the payload ``cwd`` stay exempt.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
file_path: The candidate path to classify.
|
|
81
|
+
repository_root: The PreToolUse payload's ``cwd``, or an empty string
|
|
82
|
+
when the payload names none.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Whether the path sits under a recognized agent home directory and
|
|
86
|
+
outside ``repository_root``.
|
|
87
|
+
"""
|
|
88
|
+
return is_agent_home_tooling(file_path) and not _is_at_or_under_payload_working_directory(
|
|
89
|
+
file_path, {"cwd": repository_root}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
72
93
|
def is_test_file(file_path: str) -> bool:
|
|
73
94
|
"""Check if file is a test file."""
|
|
74
95
|
path_lower = file_path.lower()
|
|
@@ -502,7 +523,7 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
|
502
523
|
checked out under ``/tmp`` or ``~/.grok/runs/`` still receives full
|
|
503
524
|
enforcement. The scratchpad match reads the session id from the payload
|
|
504
525
|
when supplied, else the harness environment variable. The code-rules and TDD gates call the underlying predicates
|
|
505
|
-
``is_ephemeral_script_path``, ``
|
|
526
|
+
``is_ephemeral_script_path``, ``is_agent_home_tooling_outside_working_directory``, and
|
|
506
527
|
``is_under_session_scratchpad`` directly.
|
|
507
528
|
|
|
508
529
|
Args:
|
|
@@ -517,9 +538,7 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
|
517
538
|
repository_root = str((hook_payload or {}).get("cwd") or "")
|
|
518
539
|
if is_ephemeral_script_path(file_path, repository_root):
|
|
519
540
|
return True
|
|
520
|
-
if
|
|
521
|
-
file_path, hook_payload or {}
|
|
522
|
-
):
|
|
541
|
+
if is_agent_home_tooling_outside_working_directory(file_path, repository_root):
|
|
523
542
|
return True
|
|
524
543
|
return is_under_session_scratchpad(file_path, hook_payload or {})
|
|
525
544
|
|
|
@@ -41,6 +41,15 @@ Each non-empty line counts as its own sentence, so a list counts one
|
|
|
41
41
|
sentence per item. URLs, markdown link targets, inline code spans, and
|
|
42
42
|
fenced blocks carry no words.
|
|
43
43
|
|
|
44
|
+
With visual reply mode on, the gate also runs the checks in
|
|
45
|
+
``visual_reply_rules.py``. The mode is off until
|
|
46
|
+
``~/.claude/visual-reply-mode.json`` holds ``{"enabled": true}``. A reply
|
|
47
|
+
or a decision card may hold no abbreviation and no tracker number outside
|
|
48
|
+
a link. A reply of more than one sentence needs a widget or a page earlier
|
|
49
|
+
in the turn. A turn sends one reply, so a reply after a delivered reply in
|
|
50
|
+
the same turn is denied. A widget may hold no anchor link, since a widget
|
|
51
|
+
link does not open in the Claude app.
|
|
52
|
+
|
|
44
53
|
A length limit is a smell elsewhere in this package, recorded and fixed in a
|
|
45
54
|
later pass. A posted reply reaches the user the moment it sends and has no
|
|
46
55
|
later pass, so this gate denies it and the model resends a shorter one.
|
|
@@ -58,6 +67,15 @@ if hooks_root_directory not in sys.path:
|
|
|
58
67
|
sys.path.insert(0, hooks_root_directory)
|
|
59
68
|
|
|
60
69
|
from json_file_reader import read_json_object
|
|
70
|
+
from visual_reply_rules import (
|
|
71
|
+
VisualReplyRules,
|
|
72
|
+
abbreviation_violation,
|
|
73
|
+
load_rules,
|
|
74
|
+
mode_enabled,
|
|
75
|
+
second_reply_violation,
|
|
76
|
+
visual_violation,
|
|
77
|
+
widget_anchor_violation,
|
|
78
|
+
)
|
|
61
79
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
62
80
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
63
81
|
from hooks_constants.reply_length_gate_constants import (
|
|
@@ -101,6 +119,7 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
101
119
|
TOO_MANY_SENTENCES_MESSAGE,
|
|
102
120
|
TOOL_INPUT_KEY,
|
|
103
121
|
TOOL_NAME_KEY,
|
|
122
|
+
TRANSCRIPT_PATH_KEY,
|
|
104
123
|
UNLINKED_PULL_REQUEST_MESSAGE,
|
|
105
124
|
UNLINKED_PULL_REQUEST_PATTERN,
|
|
106
125
|
UNSOURCED_CAUSE_MESSAGE,
|
|
@@ -176,12 +195,17 @@ def unsourced_cause_violation(reply_text: str) -> str | None:
|
|
|
176
195
|
return None
|
|
177
196
|
|
|
178
197
|
|
|
179
|
-
def
|
|
180
|
-
"""
|
|
198
|
+
def prose_outside_links(reply_text: str) -> str:
|
|
199
|
+
"""Remove fenced blocks, code spans, whole markdown links and URLs."""
|
|
181
200
|
without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
|
|
182
201
|
without_code = INLINE_CODE_PATTERN.sub(" ", without_fences)
|
|
183
202
|
without_links = MARKDOWN_LINK_PATTERN.sub(" ", without_code)
|
|
184
|
-
|
|
203
|
+
return URL_PATTERN.sub(" ", without_links)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def unlinked_pull_request_violation(reply_text: str) -> str | None:
|
|
207
|
+
"""Return the deny reason for a pull request number outside a link, or None."""
|
|
208
|
+
unlinked_match = UNLINKED_PULL_REQUEST_PATTERN.search(prose_outside_links(reply_text))
|
|
185
209
|
if unlinked_match is None:
|
|
186
210
|
return None
|
|
187
211
|
return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
|
|
@@ -240,17 +264,24 @@ def all_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
|
240
264
|
]
|
|
241
265
|
|
|
242
266
|
|
|
243
|
-
def
|
|
244
|
-
"""Return the
|
|
245
|
-
if
|
|
246
|
-
card_text = CARD_TEXT_SEPARATOR.join(all_card_texts(all_tool_input))
|
|
247
|
-
card_violation = banned_word_violation(card_text, configured_banned_words())
|
|
248
|
-
return None if card_violation is None else (card_violation, card_text)
|
|
249
|
-
if tool_name not in ALL_CHECKED_TOOL_NAMES:
|
|
250
|
-
return None
|
|
251
|
-
reply_text = all_tool_input.get(TEXT_KEY)
|
|
252
|
-
if not isinstance(reply_text, str):
|
|
267
|
+
def active_mode_rules() -> VisualReplyRules | None:
|
|
268
|
+
"""Return the visual reply rules when the mode is on and the rule file parses."""
|
|
269
|
+
if not mode_enabled(Path.home() / CLAUDE_HOME_DIRECTORY_NAME):
|
|
253
270
|
return None
|
|
271
|
+
return load_rules()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def card_violation(card_text: str, mode_rules: VisualReplyRules | None) -> str | None:
|
|
275
|
+
"""Return the deny reason for a decision card's prose, or None."""
|
|
276
|
+
return banned_word_violation(card_text, configured_banned_words()) or (
|
|
277
|
+
None if mode_rules is None else abbreviation_violation(prose_outside_links(card_text), mode_rules)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def reply_violation(
|
|
282
|
+
reply_text: str, transcript_path: object, mode_rules: VisualReplyRules | None
|
|
283
|
+
) -> str | None:
|
|
284
|
+
"""Return the deny reason for a reply's text, or None."""
|
|
254
285
|
violation = (
|
|
255
286
|
length_violation(reply_text)
|
|
256
287
|
or mid_sentence_colon_violation(reply_text)
|
|
@@ -259,6 +290,35 @@ def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tupl
|
|
|
259
290
|
or unsourced_cause_violation(reply_text)
|
|
260
291
|
or banned_word_violation(reply_text, configured_banned_words())
|
|
261
292
|
)
|
|
293
|
+
if violation is not None or mode_rules is None:
|
|
294
|
+
return violation
|
|
295
|
+
prose_text = prose_outside_links(reply_text)
|
|
296
|
+
return (
|
|
297
|
+
abbreviation_violation(prose_text, mode_rules)
|
|
298
|
+
or second_reply_violation(transcript_path, mode_rules)
|
|
299
|
+
or visual_violation(len(sentence_word_lists(reply_text)), transcript_path, mode_rules)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def tool_violation(
|
|
304
|
+
tool_name: object, all_tool_input: dict[str, object], transcript_path: object = None
|
|
305
|
+
) -> tuple[str, str] | None:
|
|
306
|
+
"""Return the deny reason and the checked text for one call, or None when it passes."""
|
|
307
|
+
mode_rules = active_mode_rules()
|
|
308
|
+
if isinstance(tool_name, str) and mode_rules is not None:
|
|
309
|
+
anchor_violation = widget_anchor_violation(tool_name, all_tool_input)
|
|
310
|
+
if anchor_violation is not None:
|
|
311
|
+
return anchor_violation, str(all_tool_input)
|
|
312
|
+
if tool_name == DECISION_CARD_TOOL_NAME:
|
|
313
|
+
card_text = CARD_TEXT_SEPARATOR.join(all_card_texts(all_tool_input))
|
|
314
|
+
violation = card_violation(card_text, mode_rules)
|
|
315
|
+
return None if violation is None else (violation, card_text)
|
|
316
|
+
if tool_name not in ALL_CHECKED_TOOL_NAMES:
|
|
317
|
+
return None
|
|
318
|
+
reply_text = all_tool_input.get(TEXT_KEY)
|
|
319
|
+
if not isinstance(reply_text, str):
|
|
320
|
+
return None
|
|
321
|
+
violation = reply_violation(reply_text, transcript_path, mode_rules)
|
|
262
322
|
return None if violation is None else (violation, reply_text)
|
|
263
323
|
|
|
264
324
|
|
|
@@ -270,7 +330,7 @@ def main() -> int:
|
|
|
270
330
|
tool_input = hook_input.get(TOOL_INPUT_KEY)
|
|
271
331
|
if not isinstance(tool_input, dict):
|
|
272
332
|
return ALLOW_EXIT_CODE
|
|
273
|
-
checked = tool_violation(tool_name, tool_input)
|
|
333
|
+
checked = tool_violation(tool_name, tool_input, hook_input.get(TRANSCRIPT_PATH_KEY))
|
|
274
334
|
if checked is None:
|
|
275
335
|
return ALLOW_EXIT_CODE
|
|
276
336
|
violation, reply_text = checked
|
|
@@ -500,7 +500,7 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
|
500
500
|
"only the current state. For example:\n"
|
|
501
501
|
' BAD: "Uses X instead of Y" → GOOD: "Uses X"\n'
|
|
502
502
|
' BAD: "Previously configured via Z" → GOOD: "Configured via Z"\n'
|
|
503
|
-
"Apply ~/.claude/
|
|
503
|
+
"Apply ~/.claude/docs/rule-guides/asd-ste100-language.md for user-facing language."
|
|
504
504
|
),
|
|
505
505
|
},
|
|
506
506
|
"systemMessage": "Agent wrote comparative/historical language - describe current state only",
|
|
@@ -11,6 +11,7 @@ so the repository code rules do not govern it::
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import json
|
|
14
15
|
import os
|
|
15
16
|
from subprocess import CompletedProcess
|
|
16
17
|
import tempfile
|
|
@@ -20,8 +21,9 @@ import pytest
|
|
|
20
21
|
|
|
21
22
|
from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
|
|
22
23
|
import code_rules_enforcer_test_support
|
|
24
|
+
from blocking.code_rules_enforcer import main as enforcer_main
|
|
23
25
|
from blocking.code_rules_shared import is_agent_home_tooling, is_ephemeral_path
|
|
24
|
-
from code_rules_enforcer_test_support import run_precheck
|
|
26
|
+
from code_rules_enforcer_test_support import run_precheck, run_serialized_payload_entrypoint
|
|
25
27
|
|
|
26
28
|
_VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
|
|
27
29
|
|
|
@@ -122,3 +124,54 @@ def test_enforcer_still_flags_the_same_source_in_a_project_path() -> None:
|
|
|
122
124
|
completed = _check_as(_VIOLATING_PRODUCTION_SOURCE, target)
|
|
123
125
|
|
|
124
126
|
assert completed.returncode != 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run_enforcer_with_payload(all_pretooluse_payload: dict[str, object]) -> str:
|
|
130
|
+
"""Drive the enforcer's stdin entry point and return what it wrote to stdout."""
|
|
131
|
+
captured_stdout, _exit_code = run_serialized_payload_entrypoint(
|
|
132
|
+
enforcer_main, json.dumps(all_pretooluse_payload)
|
|
133
|
+
)
|
|
134
|
+
return captured_stdout
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_enforcer_applies_every_rule_to_a_repository_checked_out_under_the_agent_home(
|
|
138
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
139
|
+
) -> None:
|
|
140
|
+
home_root = tmp_path_factory.mktemp("agent-home")
|
|
141
|
+
repository_directory = home_root / "home" / "example" / ".grok" / "runs" / "checkout"
|
|
142
|
+
service_module = repository_directory / "src" / "service.py"
|
|
143
|
+
service_module.parent.mkdir(parents=True)
|
|
144
|
+
service_module.write_text(
|
|
145
|
+
"def process_data(payload: str) -> None:\n return None\n", encoding="utf-8"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
captured_stdout = _run_enforcer_with_payload(
|
|
149
|
+
{
|
|
150
|
+
"tool_name": "Edit",
|
|
151
|
+
"tool_input": {
|
|
152
|
+
"file_path": str(service_module),
|
|
153
|
+
"old_string": " return None\n",
|
|
154
|
+
"new_string": " print(payload)\n",
|
|
155
|
+
},
|
|
156
|
+
"cwd": str(repository_directory),
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
assert '"permissionDecision": "deny"' in captured_stdout
|
|
161
|
+
assert "Library print()" in captured_stdout
|
|
162
|
+
assert "banned noun word" in captured_stdout
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_enforcer_keeps_agent_home_tooling_outside_the_cwd_exempt() -> None:
|
|
166
|
+
helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
|
|
167
|
+
repository_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
|
|
168
|
+
|
|
169
|
+
captured_stdout = _run_enforcer_with_payload(
|
|
170
|
+
{
|
|
171
|
+
"tool_name": "Write",
|
|
172
|
+
"tool_input": {"file_path": helper_script, "content": _VIOLATING_PRODUCTION_SOURCE},
|
|
173
|
+
"cwd": repository_directory,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
assert captured_stdout == ""
|
|
@@ -374,3 +374,36 @@ def test_ephemeral_path_stays_true_for_agent_home_file_outside_payload_cwd() ->
|
|
|
374
374
|
helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
|
|
375
375
|
|
|
376
376
|
assert _SHARED_MODULE.is_ephemeral_path(helper_script, {"cwd": checkout_directory}) is True
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_agent_home_tooling_under_the_cwd_is_not_exempt() -> None:
|
|
380
|
+
checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
|
|
381
|
+
repository_module = os.path.join(checkout_directory, "src", "service.py")
|
|
382
|
+
|
|
383
|
+
assert (
|
|
384
|
+
_SHARED_MODULE.is_agent_home_tooling_outside_working_directory(
|
|
385
|
+
repository_module, checkout_directory
|
|
386
|
+
)
|
|
387
|
+
is False
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_agent_home_tooling_outside_the_cwd_is_exempt() -> None:
|
|
392
|
+
checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
|
|
393
|
+
helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
|
|
394
|
+
|
|
395
|
+
assert _SHARED_MODULE.is_agent_home_tooling_outside_working_directory(
|
|
396
|
+
helper_script, checkout_directory
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_agent_home_tooling_with_no_cwd_is_exempt() -> None:
|
|
401
|
+
helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
|
|
402
|
+
|
|
403
|
+
assert _SHARED_MODULE.is_agent_home_tooling_outside_working_directory(helper_script, "")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_hook_infrastructure_excludes_agent_home_tooling() -> None:
|
|
407
|
+
helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
|
|
408
|
+
|
|
409
|
+
assert _SHARED_MODULE.is_hook_infrastructure(helper_script) is False
|
|
@@ -22,12 +22,15 @@ def run_gate(
|
|
|
22
22
|
capsys: pytest.CaptureFixture[str],
|
|
23
23
|
tool_name: str,
|
|
24
24
|
tool_input: dict[str, object],
|
|
25
|
+
transcript_path: Path | None = None,
|
|
25
26
|
) -> tuple[int, str]:
|
|
26
|
-
hook_input = {
|
|
27
|
+
hook_input: dict[str, object] = {
|
|
27
28
|
"hook_event_name": "PreToolUse",
|
|
28
29
|
"tool_name": tool_name,
|
|
29
30
|
"tool_input": tool_input,
|
|
30
31
|
}
|
|
32
|
+
if transcript_path is not None:
|
|
33
|
+
hook_input["transcript_path"] = str(transcript_path)
|
|
31
34
|
monkeypatch.setattr(
|
|
32
35
|
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
33
36
|
)
|
|
@@ -39,6 +42,9 @@ def run_gate(
|
|
|
39
42
|
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
40
43
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
41
44
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
45
|
+
switch_path = tmp_path / ".claude" / "visual-reply-mode.json"
|
|
46
|
+
switch_path.parent.mkdir(exist_ok=True)
|
|
47
|
+
switch_path.write_text('{"enabled": true}', encoding="utf-8")
|
|
42
48
|
|
|
43
49
|
|
|
44
50
|
def test_should_allow_three_short_sentences(
|
|
@@ -48,7 +54,7 @@ def test_should_allow_three_short_sentences(
|
|
|
48
54
|
monkeypatch,
|
|
49
55
|
capsys,
|
|
50
56
|
REPLY_TOOL_NAME,
|
|
51
|
-
{"text": "The fix is in. Tests pass
|
|
57
|
+
{"text": "The fix is in. Tests pass. It ships with the next release."},
|
|
52
58
|
)
|
|
53
59
|
assert (exit_code, stderr_text) == (0, "")
|
|
54
60
|
|
|
@@ -292,7 +298,7 @@ def test_should_replace_the_defaults_with_the_configured_list(
|
|
|
292
298
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
293
299
|
) -> None:
|
|
294
300
|
config_path = tmp_path / ".claude" / "reply-banned-words.json"
|
|
295
|
-
config_path.parent.mkdir()
|
|
301
|
+
config_path.parent.mkdir(exist_ok=True)
|
|
296
302
|
config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
|
|
297
303
|
likely_exit_code, _ = run_gate(
|
|
298
304
|
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."}
|
|
@@ -318,7 +324,7 @@ def test_should_keep_the_defaults_when_the_config_file_is_malformed(
|
|
|
318
324
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
319
325
|
) -> None:
|
|
320
326
|
config_path = tmp_path / ".claude" / "reply-banned-words.json"
|
|
321
|
-
config_path.parent.mkdir()
|
|
327
|
+
config_path.parent.mkdir(exist_ok=True)
|
|
322
328
|
config_path.write_text("{not json", encoding="utf-8")
|
|
323
329
|
exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."})
|
|
324
330
|
assert exit_code == 2
|
|
@@ -426,7 +432,7 @@ def test_should_apply_the_configured_list_to_decision_cards(
|
|
|
426
432
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
427
433
|
) -> None:
|
|
428
434
|
config_path = tmp_path / ".claude" / "reply-banned-words.json"
|
|
429
|
-
config_path.parent.mkdir()
|
|
435
|
+
config_path.parent.mkdir(exist_ok=True)
|
|
430
436
|
config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
|
|
431
437
|
probably_exit_code, _ = run_gate(
|
|
432
438
|
monkeypatch,
|
|
@@ -489,6 +495,179 @@ def test_should_deny_an_em_dash_and_allow_one_in_code(
|
|
|
489
495
|
assert "em dash" in denied_text
|
|
490
496
|
|
|
491
497
|
|
|
498
|
+
WIDGET_TOOL_NAME = "mcp__hearthbot__post_widget"
|
|
499
|
+
TWO_SENTENCE_REPLY = "The deploy stopped on a missing file. The page shows each step."
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def write_transcript(transcript_path: Path, all_entries: list[dict[str, object]]) -> Path:
|
|
503
|
+
transcript_path.write_text(
|
|
504
|
+
"\n".join(json.dumps(each_entry) for each_entry in all_entries), encoding="utf-8"
|
|
505
|
+
)
|
|
506
|
+
return transcript_path
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def prompt_entry(text: str) -> dict[str, object]:
|
|
510
|
+
return {"type": "user", "message": {"role": "user", "content": text}}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def tool_call_entry(tool_name: str) -> dict[str, object]:
|
|
514
|
+
return {
|
|
515
|
+
"type": "assistant",
|
|
516
|
+
"message": {"content": [{"type": "tool_use", "id": "t1", "name": tool_name, "input": {}}]},
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def turn_mode_off(tmp_path: Path) -> None:
|
|
521
|
+
switch_path = tmp_path / ".claude" / "visual-reply-mode.json"
|
|
522
|
+
switch_path.parent.mkdir(exist_ok=True)
|
|
523
|
+
switch_path.write_text('{"enabled": false}', encoding="utf-8")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def test_should_deny_an_abbreviation_in_a_reply(
|
|
527
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
528
|
+
) -> None:
|
|
529
|
+
exit_code, stderr_text = run_gate(
|
|
530
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The API deploy is fixed."}
|
|
531
|
+
)
|
|
532
|
+
assert exit_code == 2
|
|
533
|
+
assert 'Abbreviation "API"' in stderr_text
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_should_allow_an_abbreviation_inside_a_pull_request_link(
|
|
537
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
538
|
+
) -> None:
|
|
539
|
+
exit_code, _ = run_gate(
|
|
540
|
+
monkeypatch,
|
|
541
|
+
capsys,
|
|
542
|
+
REPLY_TOOL_NAME,
|
|
543
|
+
{"text": "It merged in [PR 5256](https://github.com/owner/repo/pull/5256)."},
|
|
544
|
+
)
|
|
545
|
+
assert exit_code == 0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_should_deny_a_tracker_item_number(
|
|
549
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
550
|
+
) -> None:
|
|
551
|
+
exit_code, stderr_text = run_gate(
|
|
552
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Item 6 is ready for you."}
|
|
553
|
+
)
|
|
554
|
+
assert exit_code == 2
|
|
555
|
+
assert 'Tracker number "Item 6"' in stderr_text
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_should_deny_an_abbreviation_in_a_decision_card(
|
|
559
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
560
|
+
) -> None:
|
|
561
|
+
exit_code, stderr_text = run_gate(
|
|
562
|
+
monkeypatch,
|
|
563
|
+
capsys,
|
|
564
|
+
DECISION_TOOL_NAME,
|
|
565
|
+
{"question": "Ship the API fix?", "options": [{"label": "Ship it", "consequence": "It merges."}]},
|
|
566
|
+
)
|
|
567
|
+
assert exit_code == 2
|
|
568
|
+
assert 'Abbreviation "API"' in stderr_text
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def test_should_deny_two_sentences_with_no_visual_this_turn(
|
|
572
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
573
|
+
) -> None:
|
|
574
|
+
transcript_path = write_transcript(
|
|
575
|
+
tmp_path / "t.jsonl",
|
|
576
|
+
[prompt_entry("why did the deploy stop"), tool_call_entry("Bash")],
|
|
577
|
+
)
|
|
578
|
+
exit_code, stderr_text = run_gate(
|
|
579
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
|
|
580
|
+
)
|
|
581
|
+
assert exit_code == 2
|
|
582
|
+
assert "no visual this turn" in stderr_text
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def test_should_allow_two_sentences_after_a_widget_this_turn(
|
|
586
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
587
|
+
) -> None:
|
|
588
|
+
transcript_path = write_transcript(
|
|
589
|
+
tmp_path / "t.jsonl",
|
|
590
|
+
[prompt_entry("why did the deploy stop"), tool_call_entry(WIDGET_TOOL_NAME)],
|
|
591
|
+
)
|
|
592
|
+
exit_code, _ = run_gate(
|
|
593
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
|
|
594
|
+
)
|
|
595
|
+
assert exit_code == 0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_should_deny_when_the_only_widget_came_before_the_last_prompt(
|
|
599
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
600
|
+
) -> None:
|
|
601
|
+
transcript_path = write_transcript(
|
|
602
|
+
tmp_path / "t.jsonl",
|
|
603
|
+
[
|
|
604
|
+
prompt_entry("why did the deploy stop"),
|
|
605
|
+
tool_call_entry(WIDGET_TOOL_NAME),
|
|
606
|
+
prompt_entry("and the logs"),
|
|
607
|
+
],
|
|
608
|
+
)
|
|
609
|
+
exit_code, _ = run_gate(
|
|
610
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
|
|
611
|
+
)
|
|
612
|
+
assert exit_code == 2
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def test_should_allow_one_sentence_with_no_visual(
|
|
616
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
617
|
+
) -> None:
|
|
618
|
+
transcript_path = write_transcript(tmp_path / "t.jsonl", [prompt_entry("merge it")])
|
|
619
|
+
exit_code, _ = run_gate(
|
|
620
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It merged."}, transcript_path
|
|
621
|
+
)
|
|
622
|
+
assert exit_code == 0
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def test_should_deny_an_anchor_link_inside_widget_code(
|
|
626
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
627
|
+
) -> None:
|
|
628
|
+
exit_code, stderr_text = run_gate(
|
|
629
|
+
monkeypatch,
|
|
630
|
+
capsys,
|
|
631
|
+
WIDGET_TOOL_NAME,
|
|
632
|
+
{"widget_code": '<div><a href="https://claude.ai/artifact/x">Review page</a></div>'},
|
|
633
|
+
)
|
|
634
|
+
assert exit_code == 2
|
|
635
|
+
assert "does not open in the Claude app" in stderr_text
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def test_should_allow_widget_code_with_no_anchor(
|
|
639
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
640
|
+
) -> None:
|
|
641
|
+
exit_code, _ = run_gate(
|
|
642
|
+
monkeypatch, capsys, WIDGET_TOOL_NAME, {"widget_code": "<div>Three steps</div>"}
|
|
643
|
+
)
|
|
644
|
+
assert exit_code == 0
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def test_should_skip_the_mode_checks_when_the_switch_turns_the_mode_off(
|
|
648
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
649
|
+
) -> None:
|
|
650
|
+
turn_mode_off(tmp_path)
|
|
651
|
+
transcript_path = write_transcript(tmp_path / "t.jsonl", [prompt_entry("status")])
|
|
652
|
+
all_exit_codes = [
|
|
653
|
+
run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The API deploy is fixed."})[0],
|
|
654
|
+
run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Which build ships first?"})[0],
|
|
655
|
+
run_gate(
|
|
656
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": TWO_SENTENCE_REPLY}, transcript_path
|
|
657
|
+
)[0],
|
|
658
|
+
run_gate(monkeypatch, capsys, WIDGET_TOOL_NAME, {"widget_code": '<a href="x">go</a>'})[0],
|
|
659
|
+
]
|
|
660
|
+
assert all_exit_codes == [0, 0, 0, 0]
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def test_should_keep_the_length_check_when_the_mode_is_off(
|
|
664
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
665
|
+
) -> None:
|
|
666
|
+
turn_mode_off(tmp_path)
|
|
667
|
+
exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "One. Two. Three. Four."})
|
|
668
|
+
assert exit_code == 2
|
|
669
|
+
|
|
670
|
+
|
|
492
671
|
def test_should_deny_the_quoted_cause_with_no_source(
|
|
493
672
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
494
673
|
) -> None:
|
|
@@ -741,7 +741,7 @@ def test_additional_context_contains_examples():
|
|
|
741
741
|
ctx = output["hookSpecificOutput"].get("additionalContext", "")
|
|
742
742
|
assert "BAD:" in ctx
|
|
743
743
|
assert "GOOD:" in ctx
|
|
744
|
-
assert "~/.claude/
|
|
744
|
+
assert "~/.claude/docs/rule-guides/asd-ste100-language.md" in ctx
|
|
745
745
|
|
|
746
746
|
|
|
747
747
|
def test_handles_non_dict_stdin():
|