claude-dev-env 2.30.1 → 2.31.1
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/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +12 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +63 -16
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
9
10
|
|
|
10
11
|
import pytest
|
|
11
12
|
|
|
@@ -37,7 +38,6 @@ from pii_prevention_blocker import ( # noqa: E402
|
|
|
37
38
|
|
|
38
39
|
HOOK_PATH = _HOOK_DIR / "pii_prevention_blocker.py"
|
|
39
40
|
|
|
40
|
-
SYNTHETIC_GITHUB_TOKEN = "ghp_" + ("C" * 36)
|
|
41
41
|
SYNTHETIC_REAL_EMAIL = "owner.fixture@acme-corp.example.io"
|
|
42
42
|
SYNTHETIC_HOME_PATH = r"C:\Users\fixture_commit_user\secret.txt"
|
|
43
43
|
SYNTHETIC_PRIVATE_IP = "10.44.12.9"
|
|
@@ -173,14 +173,12 @@ def test_gh_body_file_reads_relative_path_from_working_directory(
|
|
|
173
173
|
assert "email" in deny_reason
|
|
174
174
|
|
|
175
175
|
|
|
176
|
-
def test_gh_post_body_with_secret_is_denied() -> None:
|
|
177
|
-
command =
|
|
178
|
-
f'gh pr comment 12 --body "auth material {SYNTHETIC_GITHUB_TOKEN}"'
|
|
179
|
-
)
|
|
176
|
+
def test_gh_post_body_with_secret_is_denied(synthetic_github_token: str) -> None:
|
|
177
|
+
command = f'gh pr comment 12 --body "auth material {synthetic_github_token}"'
|
|
180
178
|
deny_reason = evaluate_bash_command(command, working_directory=None)
|
|
181
179
|
assert deny_reason is not None
|
|
182
180
|
assert "secret" in deny_reason
|
|
183
|
-
assert
|
|
181
|
+
assert synthetic_github_token not in deny_reason
|
|
184
182
|
|
|
185
183
|
|
|
186
184
|
def test_gh_post_clean_body_is_allowed() -> None:
|
|
@@ -201,12 +199,12 @@ def test_mcp_github_body_with_private_ip_is_denied() -> None:
|
|
|
201
199
|
assert "private-ip" in deny_reason
|
|
202
200
|
|
|
203
201
|
|
|
204
|
-
def test_subprocess_hook_denies_write_with_token() -> None:
|
|
202
|
+
def test_subprocess_hook_denies_write_with_token(synthetic_github_token: str) -> None:
|
|
205
203
|
payload = {
|
|
206
204
|
"tool_name": "Write",
|
|
207
205
|
"tool_input": {
|
|
208
206
|
"file_path": "src/config.env.example.md",
|
|
209
|
-
"content": f"TOKEN={
|
|
207
|
+
"content": f"TOKEN={synthetic_github_token}\n",
|
|
210
208
|
},
|
|
211
209
|
}
|
|
212
210
|
exit_code, stdout_text = _run_hook(payload)
|
|
@@ -217,7 +215,7 @@ def test_subprocess_hook_denies_write_with_token() -> None:
|
|
|
217
215
|
reason = payload_out["hookSpecificOutput"]["permissionDecisionReason"]
|
|
218
216
|
assert decision == "deny"
|
|
219
217
|
assert "secret" in reason
|
|
220
|
-
assert
|
|
218
|
+
assert synthetic_github_token not in reason
|
|
221
219
|
|
|
222
220
|
|
|
223
221
|
def test_subprocess_hook_allows_clean_write() -> None:
|
|
@@ -362,7 +360,9 @@ def test_dispatcher_roster_hosts_pii_blocker() -> None:
|
|
|
362
360
|
if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
|
|
363
361
|
]
|
|
364
362
|
assert len(matching_write_entries) == 1
|
|
365
|
-
assert matching_write_entries[0].applicable_tool_names ==
|
|
363
|
+
assert matching_write_entries[0].applicable_tool_names == (
|
|
364
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {"apply_patch"}
|
|
365
|
+
)
|
|
366
366
|
assert matching_write_entries[0].is_blocking is True
|
|
367
367
|
matching_bash_entries = [
|
|
368
368
|
each_entry
|
|
@@ -1202,3 +1202,73 @@ def test_post_body_still_scans_when_repository_is_exempt(
|
|
|
1202
1202
|
)
|
|
1203
1203
|
assert deny_reason is not None
|
|
1204
1204
|
assert "email" in deny_reason
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def test_apply_patch_add_with_real_email_is_denied(
|
|
1208
|
+
tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
|
|
1209
|
+
) -> None:
|
|
1210
|
+
repository_root = tmp_path / "repo"
|
|
1211
|
+
init_bare_git_repo(repository_root)
|
|
1212
|
+
deny_reason = evaluate(
|
|
1213
|
+
{
|
|
1214
|
+
"tool_name": "apply_patch",
|
|
1215
|
+
"cwd": str(repository_root),
|
|
1216
|
+
"tool_input": {
|
|
1217
|
+
"command": (
|
|
1218
|
+
"*** Begin Patch\n"
|
|
1219
|
+
"*** Add File: notes.md\n"
|
|
1220
|
+
f"+Reach me at {SYNTHETIC_REAL_EMAIL}\n"
|
|
1221
|
+
"*** End Patch"
|
|
1222
|
+
)
|
|
1223
|
+
},
|
|
1224
|
+
}
|
|
1225
|
+
)
|
|
1226
|
+
assert deny_reason is not None
|
|
1227
|
+
assert "email" in deny_reason
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def test_apply_patch_add_with_clean_content_is_allowed(
|
|
1231
|
+
tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
|
|
1232
|
+
) -> None:
|
|
1233
|
+
repository_root = tmp_path / "repo"
|
|
1234
|
+
init_bare_git_repo(repository_root)
|
|
1235
|
+
deny_reason = evaluate(
|
|
1236
|
+
{
|
|
1237
|
+
"tool_name": "apply_patch",
|
|
1238
|
+
"cwd": str(repository_root),
|
|
1239
|
+
"tool_input": {
|
|
1240
|
+
"command": (
|
|
1241
|
+
"*** Begin Patch\n"
|
|
1242
|
+
"*** Add File: notes.md\n"
|
|
1243
|
+
"+Reach me at user@example.com\n"
|
|
1244
|
+
"*** End Patch"
|
|
1245
|
+
)
|
|
1246
|
+
},
|
|
1247
|
+
}
|
|
1248
|
+
)
|
|
1249
|
+
assert deny_reason is None
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def test_subprocess_hook_denies_apply_patch_add_with_token(
|
|
1253
|
+
tmp_path: Path,
|
|
1254
|
+
init_bare_git_repo: Callable[[Path], None],
|
|
1255
|
+
synthetic_github_token: str,
|
|
1256
|
+
) -> None:
|
|
1257
|
+
"""The standalone hook process denies an apply_patch payload carrying a token."""
|
|
1258
|
+
repository_root = tmp_path / "repo"
|
|
1259
|
+
init_bare_git_repo(repository_root)
|
|
1260
|
+
payload = {
|
|
1261
|
+
"tool_name": "apply_patch",
|
|
1262
|
+
"cwd": str(repository_root),
|
|
1263
|
+
"tool_input": {
|
|
1264
|
+
"command": (
|
|
1265
|
+
"*** Begin Patch\n"
|
|
1266
|
+
"*** Add File: notes.md\n"
|
|
1267
|
+
f"+token is {synthetic_github_token}\n"
|
|
1268
|
+
"*** End Patch"
|
|
1269
|
+
)
|
|
1270
|
+
},
|
|
1271
|
+
}
|
|
1272
|
+
returncode, stdout = _run_hook(payload)
|
|
1273
|
+
assert returncode == 0
|
|
1274
|
+
assert json.loads(stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Unit tests for the pr-description-writer PreToolUse gate on gh pr create."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
_HOOKS_DIRECTORY = _BLOCKING_DIRECTORY.parent
|
|
16
|
+
for each_directory in (_BLOCKING_DIRECTORY, _HOOKS_DIRECTORY):
|
|
17
|
+
if str(each_directory) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(each_directory))
|
|
19
|
+
|
|
20
|
+
_GATE_SPEC = importlib.util.spec_from_file_location(
|
|
21
|
+
"pr_description_writer_gate",
|
|
22
|
+
_BLOCKING_DIRECTORY / "pr_description_writer_gate.py",
|
|
23
|
+
)
|
|
24
|
+
assert _GATE_SPEC is not None
|
|
25
|
+
assert _GATE_SPEC.loader is not None
|
|
26
|
+
gate_module = importlib.util.module_from_spec(_GATE_SPEC)
|
|
27
|
+
_GATE_SPEC.loader.exec_module(gate_module)
|
|
28
|
+
|
|
29
|
+
_GATE_CONSTANTS = importlib.import_module(
|
|
30
|
+
"hooks_constants.pr_description_writer_gate_constants"
|
|
31
|
+
)
|
|
32
|
+
_PR_DESCRIPTION_WRITER_SUBAGENT_TYPE = _GATE_CONSTANTS.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE
|
|
33
|
+
|
|
34
|
+
_SESSION_ID = "session-abc123"
|
|
35
|
+
_PLAIN_CREATE_COMMAND = 'gh pr create --draft --title "feat: add it" --body-file body.md'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _payload(command: str, tool_name: str = "Bash") -> dict[str, object]:
|
|
39
|
+
"""Build a PreToolUse payload carrying one shell command.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
command: The shell command the gate inspects.
|
|
43
|
+
tool_name: The tool the command runs through.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The payload dictionary the gate reads from stdin.
|
|
47
|
+
"""
|
|
48
|
+
return {
|
|
49
|
+
"tool_name": tool_name,
|
|
50
|
+
"session_id": _SESSION_ID,
|
|
51
|
+
"tool_input": {"command": command},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _printed_output_for(command: str, temp_directory: Path, tool_name: str = "Bash") -> str:
|
|
56
|
+
"""Run the gate against one command and return everything it printed.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
command: The shell command the gate inspects.
|
|
60
|
+
temp_directory: The directory standing in for the system temp root.
|
|
61
|
+
tool_name: The tool the command runs through.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The gate's stripped stdout — empty on an allow.
|
|
65
|
+
"""
|
|
66
|
+
captured_stdout = io.StringIO()
|
|
67
|
+
payload_text = json.dumps(_payload(command, tool_name))
|
|
68
|
+
with (
|
|
69
|
+
mock.patch("sys.stdin", io.StringIO(payload_text)),
|
|
70
|
+
mock.patch("sys.stdout", captured_stdout),
|
|
71
|
+
mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(temp_directory)),
|
|
72
|
+
pytest.raises(SystemExit),
|
|
73
|
+
):
|
|
74
|
+
gate_module.main()
|
|
75
|
+
return captured_stdout.getvalue().strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _decision_for(command: str, temp_directory: Path, tool_name: str = "Bash") -> str:
|
|
79
|
+
"""Return the gate's permission decision for one command.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
command: The shell command the gate inspects.
|
|
83
|
+
temp_directory: The directory standing in for the system temp root.
|
|
84
|
+
tool_name: The tool the command runs through.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The ``permissionDecision`` string, or an empty string on an allow.
|
|
88
|
+
"""
|
|
89
|
+
printed_text = _printed_output_for(command, temp_directory, tool_name)
|
|
90
|
+
if not printed_text:
|
|
91
|
+
return ""
|
|
92
|
+
hook_output = json.loads(printed_text).get("hookSpecificOutput", {})
|
|
93
|
+
return str(hook_output.get("permissionDecision", ""))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _record_spawn(temp_directory: Path, session_id: str = _SESSION_ID) -> None:
|
|
97
|
+
"""Write the marker file that stands for a recorded agent spawn.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
temp_directory: The directory standing in for the system temp root.
|
|
101
|
+
"""
|
|
102
|
+
marker_name = (
|
|
103
|
+
f"{_GATE_CONSTANTS.SPAWN_MARKER_FILE_PREFIX}{session_id}"
|
|
104
|
+
f"{_GATE_CONSTANTS.SPAWN_MARKER_FILE_SUFFIX}"
|
|
105
|
+
)
|
|
106
|
+
(temp_directory / marker_name).touch()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def should_deny_pr_create_with_no_recorded_spawn(tmp_path: Path) -> None:
|
|
110
|
+
"""A gh pr create with no recorded spawn is denied."""
|
|
111
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == "deny"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def should_allow_pr_create_after_a_recorded_spawn(tmp_path: Path) -> None:
|
|
115
|
+
"""A gh pr create is allowed once this session recorded a spawn."""
|
|
116
|
+
_record_spawn(tmp_path)
|
|
117
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def should_allow_pr_create_carrying_the_bypass_marker(tmp_path: Path) -> None:
|
|
121
|
+
"""A trailing bypass comment allows the create with no recorded spawn."""
|
|
122
|
+
command = f"{_PLAIN_CREATE_COMMAND} {gate_module.SPAWN_BYPASS_MARKER}"
|
|
123
|
+
assert _decision_for(command, tmp_path) == ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def should_allow_multiline_pr_create_carrying_the_bypass_marker(tmp_path: Path) -> None:
|
|
127
|
+
"""A final bypass comment allows a create after a continued shell line."""
|
|
128
|
+
command = f"cd . && \\\n+{_PLAIN_CREATE_COMMAND} {gate_module.SPAWN_BYPASS_MARKER}"
|
|
129
|
+
assert _decision_for(command, tmp_path) == ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def should_deny_when_the_bypass_marker_follows_another_shell_segment(tmp_path: Path) -> None:
|
|
133
|
+
"""A final marker for echo cannot opt a preceding create out."""
|
|
134
|
+
command = f"{_PLAIN_CREATE_COMMAND} && echo {gate_module.SPAWN_BYPASS_MARKER}"
|
|
135
|
+
assert _decision_for(command, tmp_path) == "deny"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def should_deny_when_only_another_session_has_a_spawn_marker(tmp_path: Path) -> None:
|
|
139
|
+
"""A marker from another session cannot unlock this session's create."""
|
|
140
|
+
_record_spawn(tmp_path, session_id="earlier-session")
|
|
141
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == "deny"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def should_deny_when_the_marker_text_sits_inside_a_quoted_value(tmp_path: Path) -> None:
|
|
145
|
+
"""The bypass text inside a quoted title leaves the gate running."""
|
|
146
|
+
command = 'gh pr create --title "fix # pr-description-skip" --body-file body.md'
|
|
147
|
+
assert _decision_for(command, tmp_path) == "deny"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def should_deny_a_pr_create_that_follows_another_command(tmp_path: Path) -> None:
|
|
151
|
+
"""A create in a later command segment is still gated."""
|
|
152
|
+
command = f"cd /repo && {_PLAIN_CREATE_COMMAND}"
|
|
153
|
+
assert _decision_for(command, tmp_path) == "deny"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.parametrize("shell_tool_name", ["Bash", "PowerShell"])
|
|
157
|
+
def should_gate_each_shell_tool(shell_tool_name: str, tmp_path: Path) -> None:
|
|
158
|
+
"""Both shell tools reach the gate."""
|
|
159
|
+
decision = _decision_for(_PLAIN_CREATE_COMMAND, tmp_path, tool_name=shell_tool_name)
|
|
160
|
+
assert decision == "deny"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize(
|
|
164
|
+
"unrelated_command",
|
|
165
|
+
[
|
|
166
|
+
"gh pr edit 12 --body-file body.md",
|
|
167
|
+
"gh pr comment 12 --body-file body.md",
|
|
168
|
+
"gh pr ready 12",
|
|
169
|
+
"gh issue create --title x --body-file body.md",
|
|
170
|
+
"git commit -m x",
|
|
171
|
+
"echo gh pr create",
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
def should_stay_quiet_for_a_command_that_creates_no_pull_request(
|
|
175
|
+
unrelated_command: str, tmp_path: Path
|
|
176
|
+
) -> None:
|
|
177
|
+
"""A command other than gh pr create passes untouched."""
|
|
178
|
+
assert _decision_for(unrelated_command, tmp_path) == ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def should_stay_quiet_for_a_tool_that_runs_no_shell(tmp_path: Path) -> None:
|
|
182
|
+
"""A non-shell tool carrying the same text passes untouched."""
|
|
183
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path, tool_name="Write") == ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def should_deny_when_the_payload_names_no_session(tmp_path: Path) -> None:
|
|
187
|
+
"""A payload with no session id reads as no record and denies.
|
|
188
|
+
|
|
189
|
+
A shared fallback marker would let one session's spawn silently clear a
|
|
190
|
+
later session's create, so an unusable session id denies instead.
|
|
191
|
+
"""
|
|
192
|
+
_record_spawn(tmp_path)
|
|
193
|
+
captured_stdout = io.StringIO()
|
|
194
|
+
payload = _payload(_PLAIN_CREATE_COMMAND)
|
|
195
|
+
payload["session_id"] = ""
|
|
196
|
+
with (
|
|
197
|
+
mock.patch("sys.stdin", io.StringIO(json.dumps(payload))),
|
|
198
|
+
mock.patch("sys.stdout", captured_stdout),
|
|
199
|
+
mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(tmp_path)),
|
|
200
|
+
pytest.raises(SystemExit),
|
|
201
|
+
):
|
|
202
|
+
gate_module.main()
|
|
203
|
+
hook_output = json.loads(captured_stdout.getvalue().strip())["hookSpecificOutput"]
|
|
204
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def should_allow_an_untokenizable_command(tmp_path: Path) -> None:
|
|
208
|
+
"""A command that will not tokenize fails open."""
|
|
209
|
+
assert _decision_for('gh pr create --title "unclosed', tmp_path) == ""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def should_name_both_ways_forward_in_the_denial(tmp_path: Path) -> None:
|
|
213
|
+
"""The denial text names the agent spawn and the bypass marker."""
|
|
214
|
+
printed_text = _printed_output_for(_PLAIN_CREATE_COMMAND, tmp_path)
|
|
215
|
+
denial_reason = json.loads(printed_text)["hookSpecificOutput"]["permissionDecisionReason"]
|
|
216
|
+
assert _PR_DESCRIPTION_WRITER_SUBAGENT_TYPE in denial_reason
|
|
217
|
+
assert gate_module.SPAWN_BYPASS_MARKER in denial_reason
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def should_read_the_marker_path_the_shared_builder_produces(tmp_path: Path) -> None:
|
|
221
|
+
"""The gate reads exactly the file the shared path builder names.
|
|
222
|
+
|
|
223
|
+
The tracker writes through `spawn_marker_path` and the gate reads through it,
|
|
224
|
+
so neither side owns a private copy of the marker file name.
|
|
225
|
+
"""
|
|
226
|
+
marker_file = _GATE_CONSTANTS.spawn_marker_path(_SESSION_ID, temp_directory=tmp_path)
|
|
227
|
+
assert marker_file is not None
|
|
228
|
+
marker_file.touch()
|
|
229
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def should_deny_when_the_session_id_names_no_marker(tmp_path: Path) -> None:
|
|
233
|
+
"""A session id that sanitizes to nothing yields no path, so the gate denies."""
|
|
234
|
+
assert _GATE_CONSTANTS.spawn_marker_path("", temp_directory=tmp_path) is None
|