claude-dev-env 2.18.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -9,37 +9,97 @@ a ``deny`` so the sandbox can be contained.
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
- import os
13
12
  import subprocess
14
13
  import sys
15
14
  from pathlib import Path
16
15
 
17
- SCRIPT_PATH = Path(__file__).parent / "destructive_command_blocker.py"
18
- DENY_MODE_ENV_VAR = "CLAUDE_DESTRUCTIVE_DENY_MODE"
19
-
20
-
21
- def _run_hook_with_environment(command: str, extra_environment: dict[str, str]) -> dict:
22
- child_environment = os.environ.copy()
23
- child_environment.pop(DENY_MODE_ENV_VAR, None)
24
- child_environment.update(extra_environment)
25
- completed_process = subprocess.run(
26
- [sys.executable, str(SCRIPT_PATH)],
27
- input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}),
28
- text=True,
29
- capture_output=True,
30
- check=False,
31
- env=child_environment,
16
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
17
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
18
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
19
+
20
+ SCRIPT_PATH = _BLOCKING_DIRECTORY / "destructive_command_blocker.py"
21
+ DISPATCHER_PATH = _BLOCKING_DIRECTORY / "bash_pre_tool_use_dispatcher.py"
22
+
23
+ import _path_setup # noqa: E402, F401
24
+
25
+ from test_hook_subprocess_support import ( # noqa: E402
26
+ build_bash_payload,
27
+ run_hook_as_subprocess,
28
+ )
29
+
30
+
31
+ def _run_hook_with_environment(
32
+ hook_script_path: Path,
33
+ command: str,
34
+ environment_update_by_name: dict[str, str],
35
+ temporary_home_directory: Path,
36
+ ) -> subprocess.CompletedProcess[str]:
37
+ return run_hook_as_subprocess(
38
+ hook_script_path=hook_script_path,
39
+ payload_text=build_bash_payload(command),
40
+ working_directory=temporary_home_directory,
41
+ home_directory=temporary_home_directory,
42
+ all_environment_names_to_remove=("CLAUDE_DESTRUCTIVE_DENY_MODE",),
43
+ environment_updates_by_name=environment_update_by_name,
32
44
  )
33
- return json.loads(completed_process.stdout)
34
45
 
35
46
 
36
- def test_rm_rf_denies_when_deny_mode_env_is_set() -> None:
37
- response = _run_hook_with_environment("rm -rf /var/log/myapp", {DENY_MODE_ENV_VAR: "1"})
38
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
39
- assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
47
+ def _assert_terminal_response(
48
+ completed_hook: subprocess.CompletedProcess[str],
49
+ expected_permission_decision: str,
50
+ expected_permission_reason: str,
51
+ ) -> None:
52
+ assert completed_hook.returncode == 0
53
+ assert completed_hook.stderr == ""
54
+ assert json.loads(completed_hook.stdout) == {
55
+ "hookSpecificOutput": {
56
+ "hookEventName": "PreToolUse",
57
+ "permissionDecision": expected_permission_decision,
58
+ "permissionDecisionReason": expected_permission_reason,
59
+ }
60
+ }
61
+
40
62
 
63
+ def test_rm_rf_denies_when_deny_mode_env_is_set(tmp_path: Path) -> None:
64
+ completed_hook = _run_hook_with_environment(
65
+ SCRIPT_PATH,
66
+ "rm -rf /var/log/myapp",
67
+ {"CLAUDE_DESTRUCTIVE_DENY_MODE": "1"},
68
+ tmp_path,
69
+ )
70
+ _assert_terminal_response(
71
+ completed_hook,
72
+ "deny",
73
+ "DESTRUCTIVE: rm -rf (destructive recursive forced delete). "
74
+ "Blocked in deny mode.",
75
+ )
41
76
 
42
- def test_rm_rf_asks_when_deny_mode_env_is_absent() -> None:
43
- response = _run_hook_with_environment("rm -rf /var/log/myapp", {})
44
- assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
45
- assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
77
+
78
+ def test_rm_rf_asks_when_deny_mode_env_is_absent(tmp_path: Path) -> None:
79
+ completed_hook = _run_hook_with_environment(
80
+ SCRIPT_PATH,
81
+ "rm -rf /var/log/myapp",
82
+ {},
83
+ tmp_path,
84
+ )
85
+ _assert_terminal_response(
86
+ completed_hook,
87
+ "ask",
88
+ "DESTRUCTIVE: rm -rf (destructive recursive forced delete). "
89
+ "Requires explicit user approval.",
90
+ )
91
+
92
+
93
+ def test_dispatcher_short_circuits_on_destructive_deny(tmp_path: Path) -> None:
94
+ completed_dispatcher = _run_hook_with_environment(
95
+ DISPATCHER_PATH,
96
+ "rm -rf /var/log/myapp",
97
+ {"CLAUDE_DESTRUCTIVE_DENY_MODE": "1"},
98
+ tmp_path,
99
+ )
100
+ _assert_terminal_response(
101
+ completed_dispatcher,
102
+ "deny",
103
+ "DESTRUCTIVE: rm -rf (destructive recursive forced delete). "
104
+ "Blocked in deny mode.",
105
+ )
@@ -5,34 +5,30 @@ hook signing and verification. The blocker must ASK before allowing them.
5
5
  """
6
6
 
7
7
  import json
8
- import os
9
- import subprocess
10
8
  import sys
11
9
  from pathlib import Path
12
10
 
11
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
12
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
13
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
13
14
 
14
- SCRIPT_PATH = Path(__file__).parent / "destructive_command_blocker.py"
15
+ import _path_setup # noqa: E402, F401
15
16
 
17
+ from test_hook_subprocess_support import ( # noqa: E402
18
+ build_bash_payload,
19
+ run_hook_as_subprocess,
20
+ )
16
21
 
17
- def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
18
- child_environment = os.environ.copy()
19
- return subprocess.run(
20
- [sys.executable, str(SCRIPT_PATH)],
21
- input=json.dumps(payload),
22
- text=True,
23
- capture_output=True,
24
- check=False,
25
- env=child_environment,
26
- )
27
-
28
-
29
- def _make_bash_payload(command: str) -> dict:
30
- return {"tool_name": "Bash", "tool_input": {"command": command}}
22
+ SCRIPT_PATH = _BLOCKING_DIRECTORY / "destructive_command_blocker.py"
31
23
 
32
24
 
33
- def test_asks_on_git_commit_no_verify() -> None:
34
- payload = _make_bash_payload('git commit -m "wip" --no-verify')
35
- result = _run_hook(payload)
25
+ def test_asks_on_git_commit_no_verify(tmp_path: Path) -> None:
26
+ result = run_hook_as_subprocess(
27
+ hook_script_path=SCRIPT_PATH,
28
+ payload_text=build_bash_payload('git commit -m "wip" --no-verify'),
29
+ working_directory=tmp_path,
30
+ home_directory=tmp_path,
31
+ )
36
32
  response = json.loads(result.stdout)
37
33
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
38
34
  f"Expected ask for git commit --no-verify, got: {response!r}"
@@ -42,18 +38,26 @@ def test_asks_on_git_commit_no_verify() -> None:
42
38
  )
43
39
 
44
40
 
45
- def test_asks_on_git_push_no_verify() -> None:
46
- payload = _make_bash_payload("git push --no-verify origin main")
47
- result = _run_hook(payload)
41
+ def test_asks_on_git_push_no_verify(tmp_path: Path) -> None:
42
+ result = run_hook_as_subprocess(
43
+ hook_script_path=SCRIPT_PATH,
44
+ payload_text=build_bash_payload("git push --no-verify origin main"),
45
+ working_directory=tmp_path,
46
+ home_directory=tmp_path,
47
+ )
48
48
  response = json.loads(result.stdout)
49
49
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
50
50
  f"Expected ask for git push --no-verify, got: {response!r}"
51
51
  )
52
52
 
53
53
 
54
- def test_asks_on_git_no_gpg_sign() -> None:
55
- payload = _make_bash_payload("git commit --no-gpg-sign -m wip")
56
- result = _run_hook(payload)
54
+ def test_asks_on_git_no_gpg_sign(tmp_path: Path) -> None:
55
+ result = run_hook_as_subprocess(
56
+ hook_script_path=SCRIPT_PATH,
57
+ payload_text=build_bash_payload("git commit --no-gpg-sign -m wip"),
58
+ working_directory=tmp_path,
59
+ home_directory=tmp_path,
60
+ )
57
61
  response = json.loads(result.stdout)
58
62
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
59
63
  f"Expected ask for git --no-gpg-sign, got: {response!r}"
@@ -63,36 +67,52 @@ def test_asks_on_git_no_gpg_sign() -> None:
63
67
  ), f"Reason must mention --no-gpg-sign, got: {response!r}"
64
68
 
65
69
 
66
- def test_asks_on_git_commit_with_no_gpg_sign_config() -> None:
67
- payload = _make_bash_payload("git -c commit.gpgsign=false commit -m wip")
68
- result = _run_hook(payload)
70
+ def test_asks_on_git_commit_with_no_gpg_sign_config(tmp_path: Path) -> None:
71
+ result = run_hook_as_subprocess(
72
+ hook_script_path=SCRIPT_PATH,
73
+ payload_text=build_bash_payload("git -c commit.gpgsign=false commit -m wip"),
74
+ working_directory=tmp_path,
75
+ home_directory=tmp_path,
76
+ )
69
77
  response = json.loads(result.stdout)
70
78
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
71
79
  f"Expected ask for -c commit.gpgsign=false, got: {response!r}"
72
80
  )
73
81
 
74
82
 
75
- def test_asks_on_quoted_gpgsign_config() -> None:
76
- payload = _make_bash_payload("git -c 'commit.gpgsign=false' commit -m wip")
77
- result = _run_hook(payload)
83
+ def test_asks_on_quoted_gpgsign_config(tmp_path: Path) -> None:
84
+ result = run_hook_as_subprocess(
85
+ hook_script_path=SCRIPT_PATH,
86
+ payload_text=build_bash_payload("git -c 'commit.gpgsign=false' commit -m wip"),
87
+ working_directory=tmp_path,
88
+ home_directory=tmp_path,
89
+ )
78
90
  response = json.loads(result.stdout)
79
91
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
80
92
  f"Expected ask for quoted -c commit.gpgsign=false, got: {response!r}"
81
93
  )
82
94
 
83
95
 
84
- def test_asks_on_value_quoted_gpgsign_config() -> None:
85
- payload = _make_bash_payload("git -c commit.gpgsign='false' commit -m wip")
86
- result = _run_hook(payload)
96
+ def test_asks_on_value_quoted_gpgsign_config(tmp_path: Path) -> None:
97
+ result = run_hook_as_subprocess(
98
+ hook_script_path=SCRIPT_PATH,
99
+ payload_text=build_bash_payload("git -c commit.gpgsign='false' commit -m wip"),
100
+ working_directory=tmp_path,
101
+ home_directory=tmp_path,
102
+ )
87
103
  response = json.loads(result.stdout)
88
104
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
89
105
  f"Expected ask for value-quoted -c commit.gpgsign='false', got: {response!r}"
90
106
  )
91
107
 
92
108
 
93
- def test_normal_git_commit_passes() -> None:
94
- payload = _make_bash_payload('git commit -m "real commit"')
95
- result = _run_hook(payload)
109
+ def test_normal_git_commit_passes(tmp_path: Path) -> None:
110
+ result = run_hook_as_subprocess(
111
+ hook_script_path=SCRIPT_PATH,
112
+ payload_text=build_bash_payload('git commit -m "real commit"'),
113
+ working_directory=tmp_path,
114
+ home_directory=tmp_path,
115
+ )
96
116
  if not result.stdout.strip():
97
117
  return
98
118
  response = json.loads(result.stdout)
@@ -0,0 +1,133 @@
1
+ """Production-subprocess coverage for destructive command patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
14
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
15
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
16
+
17
+ import _path_setup # noqa: E402, F401
18
+
19
+ from hooks_constants.destructive_command_environment_constants import ( # noqa: E402
20
+ DESTRUCTIVE_DENY_MODE_ENV_VAR,
21
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
22
+ )
23
+ from test_hook_subprocess_support import ( # noqa: E402
24
+ build_bash_payload,
25
+ run_hook_as_subprocess,
26
+ )
27
+
28
+ SCRIPT_PATH = _BLOCKING_DIRECTORY / "destructive_command_blocker.py"
29
+ ALL_DESTRUCTIVE_ENVIRONMENT_NAMES = (
30
+ DESTRUCTIVE_DENY_MODE_ENV_VAR,
31
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
32
+ )
33
+
34
+
35
+ def _run_hook(
36
+ command: str,
37
+ working_directory: Path,
38
+ home_directory: Path,
39
+ ) -> subprocess.CompletedProcess[str]:
40
+ return run_hook_as_subprocess(
41
+ hook_script_path=SCRIPT_PATH,
42
+ payload_text=build_bash_payload(command),
43
+ working_directory=working_directory,
44
+ home_directory=home_directory,
45
+ all_environment_names_to_remove=ALL_DESTRUCTIVE_ENVIRONMENT_NAMES,
46
+ )
47
+
48
+
49
+ @pytest.mark.parametrize(
50
+ ("command", "reason_fragment"),
51
+ [
52
+ ("rm -rf /var/log/myapp", "rm -rf"),
53
+ ("rm --recursive --force /var/log/myapp", "rm --recursive --force"),
54
+ ("rm -r /", "rm -r on broad path"),
55
+ ("mkfs.ext4 /dev/sda1", "mkfs"),
56
+ ("dd if=/tmp/disk.img of=/dev/sda", "dd raw disk write"),
57
+ ("git reset --hard HEAD~1", "git reset --hard"),
58
+ ("git push --force origin main", "git push --force"),
59
+ ("git push -f origin main", "git push -f"),
60
+ ("git clean -fd", "git clean -fd"),
61
+ ("git clean -f", "git clean -f"),
62
+ ("psql -c 'DROP TABLE users'", "DROP TABLE"),
63
+ ("psql -c 'DROP DATABASE app'", "DROP DATABASE"),
64
+ ("psql -c 'TRUNCATE TABLE users'", "TRUNCATE TABLE"),
65
+ ("git commit --no-verify", "git --no-verify"),
66
+ ("git commit --no-gpg-sign", "git --no-gpg-sign"),
67
+ ("git -c commit.gpgsign=false commit", "commit.gpgsign=false"),
68
+ ],
69
+ )
70
+ def test_protected_command_asks_for_approval(
71
+ command: str,
72
+ reason_fragment: str,
73
+ tmp_path: Path,
74
+ ) -> None:
75
+ filesystem_root = Path(Path.cwd().anchor)
76
+ completed_hook = _run_hook(command, filesystem_root, tmp_path)
77
+
78
+ hook_decision_payload = json.loads(completed_hook.stdout)
79
+ assert completed_hook.returncode == 0
80
+ assert completed_hook.stderr == ""
81
+ assert hook_decision_payload["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
82
+ assert hook_decision_payload["hookSpecificOutput"]["permissionDecision"] == "ask"
83
+ assert reason_fragment in hook_decision_payload["hookSpecificOutput"][
84
+ "permissionDecisionReason"
85
+ ]
86
+
87
+
88
+ @pytest.mark.parametrize(
89
+ "command",
90
+ [
91
+ "git status --short",
92
+ "rm -rf /tmp/destructive-command-blocker/build",
93
+ "rm -rf /project/worktrees/feature/build",
94
+ "git push --force origin claude/fix-hook-test",
95
+ ],
96
+ )
97
+ def test_permitted_command_exits_silently(command: str, tmp_path: Path) -> None:
98
+ completed_hook = _run_hook(command, tmp_path, tmp_path)
99
+
100
+ assert completed_hook.returncode == 0
101
+ assert completed_hook.stdout == ""
102
+ assert completed_hook.stderr == ""
103
+
104
+
105
+ def test_ephemeral_rm_exits_silently_when_parent_disables_auto_allow(
106
+ monkeypatch: pytest.MonkeyPatch,
107
+ tmp_path: Path,
108
+ ) -> None:
109
+ monkeypatch.setenv(EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR, "1")
110
+
111
+ with tempfile.TemporaryDirectory(
112
+ dir=tmp_path,
113
+ prefix="destructive command blocker ",
114
+ ) as ephemeral_directory_path:
115
+ completed_hook = _run_hook(
116
+ f'rm -rf "{ephemeral_directory_path}"',
117
+ tmp_path,
118
+ tmp_path,
119
+ )
120
+
121
+ assert completed_hook.returncode == 0
122
+ assert completed_hook.stdout == ""
123
+ assert completed_hook.stderr == ""
124
+
125
+
126
+ def test_git_reset_hard_in_ephemeral_project_exits_silently(
127
+ tmp_path: Path,
128
+ ) -> None:
129
+ completed_hook = _run_hook("git reset --hard HEAD~1", tmp_path, tmp_path)
130
+
131
+ assert completed_hook.returncode == 0
132
+ assert completed_hook.stdout == ""
133
+ assert completed_hook.stderr == ""
@@ -2,11 +2,63 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import runpy
5
6
  import subprocess
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
9
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
10
+ _local_hooks_directory = str(Path(__file__).resolve().parent.parent)
11
+ _all_local_import_directories = (
12
+ str(Path(_local_hooks_directory) / "blocking"),
13
+ _local_hooks_directory,
14
+ )
15
+ _all_normalized_local_import_directories = tuple(
16
+ os.path.normcase(os.path.realpath(each_directory))
17
+ for each_directory in _all_local_import_directories
18
+ )
19
+ _all_normalized_import_directories = tuple(
20
+ (
21
+ os.path.normcase(os.path.realpath(os.fspath(each_directory)))
22
+ if each_directory != "" and isinstance(each_directory, (str, os.PathLike))
23
+ else None
24
+ )
25
+ for each_directory in sys.path
26
+ )
27
+ _all_prioritized_import_directories = [
28
+ *_all_local_import_directories,
29
+ *(
30
+ each_directory
31
+ for each_directory, normalized_directory in zip(
32
+ sys.path,
33
+ _all_normalized_import_directories,
34
+ )
35
+ if normalized_directory not in _all_normalized_local_import_directories
36
+ ),
37
+ ]
38
+ if sys.path != _all_prioritized_import_directories:
39
+ sys.path[:] = _all_prioritized_import_directories
40
+
41
+ _all_local_module_prefixes = ("docstring_rule_gate_count_blocker", "hooks_constants")
42
+ for each_module_name, each_module in tuple(sys.modules.items()):
43
+ if not isinstance(each_module_name, str) or not any(
44
+ each_module_name == each_prefix or each_module_name.startswith(each_prefix + ".")
45
+ for each_prefix in _all_local_module_prefixes
46
+ ):
47
+ continue
48
+ module_file = getattr(each_module, "__file__", None)
49
+ is_local_module = False
50
+ if isinstance(module_file, (str, os.PathLike)):
51
+ try:
52
+ is_local_module = Path(module_file).resolve().is_relative_to(_local_hooks_directory)
53
+ except (OSError, RuntimeError, TypeError, ValueError):
54
+ is_local_module = False
55
+ if not is_local_module:
56
+ sys.modules.pop(each_module_name, None)
57
+
58
+ del _all_local_module_prefixes
59
+ del _all_normalized_import_directories
60
+ del _all_normalized_local_import_directories
61
+ del _all_prioritized_import_directories
10
62
 
11
63
  from docstring_rule_gate_count_blocker import (
12
64
  find_gate_count_drift,
@@ -105,6 +157,66 @@ def _target_rule_path(tmp_path: Path) -> Path:
105
157
  return tmp_path / TARGET_RULE_BASENAME
106
158
 
107
159
 
160
+ def _run_test_module_bootstrap(
161
+ all_import_directories: list[str], run_count: int
162
+ ) -> list[str]:
163
+ """Run this test module against a supplied import path.
164
+
165
+ Args:
166
+ all_import_directories: Import path entries supplied to the module.
167
+ run_count: Number of module executions.
168
+
169
+ Returns:
170
+ Import path after the final module execution.
171
+ """
172
+ all_original_import_directories = list(sys.path)
173
+ try:
174
+ sys.path[:] = all_import_directories
175
+ for each_run_number in range(run_count):
176
+ runpy.run_path(__file__, run_name=f"bootstrap_probe_{each_run_number}")
177
+ return list(sys.path)
178
+ finally:
179
+ sys.path[:] = all_original_import_directories
180
+
181
+
182
+ def should_prioritize_local_import_directories_over_foreign_hooks(tmp_path: Path) -> None:
183
+ foreign_hooks_directory = str(tmp_path / "hooks")
184
+ all_import_directories = [
185
+ foreign_hooks_directory,
186
+ _local_hooks_directory,
187
+ "",
188
+ "tail",
189
+ ]
190
+
191
+ assert _run_test_module_bootstrap(all_import_directories, 1) == [
192
+ *_all_local_import_directories,
193
+ foreign_hooks_directory,
194
+ "",
195
+ "tail",
196
+ ]
197
+
198
+
199
+ def should_prioritize_local_import_directories_idempotently(tmp_path: Path) -> None:
200
+ foreign_hooks_directory = str(tmp_path / "hooks")
201
+ all_import_directories = [
202
+ foreign_hooks_directory,
203
+ _local_hooks_directory,
204
+ _all_local_import_directories[0] + os.sep + ".",
205
+ _local_hooks_directory + os.sep + ".",
206
+ "tail",
207
+ ]
208
+
209
+ all_import_directories_after_runs = _run_test_module_bootstrap(all_import_directories, 2)
210
+
211
+ assert all_import_directories_after_runs == [
212
+ *_all_local_import_directories,
213
+ foreign_hooks_directory,
214
+ "tail",
215
+ ]
216
+ assert all_import_directories_after_runs.count(_all_local_import_directories[0]) == 1
217
+ assert all_import_directories_after_runs.count(_local_hooks_directory) == 1
218
+
219
+
108
220
  def should_flag_target_rule_basename() -> None:
109
221
  assert is_target_rule_file("/somewhere/" + TARGET_RULE_BASENAME) is True
110
222
 
@@ -8,17 +8,41 @@ environment variable to a code file whose source never references that variable.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import json
12
+ import subprocess
11
13
  import sys
12
14
  from pathlib import Path
13
15
 
14
- _blocking_dir = str(Path(__file__).resolve().parent)
15
- if _blocking_dir not in sys.path:
16
- sys.path.insert(0, _blocking_dir)
16
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
17
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
18
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
17
19
 
18
20
  from env_var_table_code_drift_blocker import ( # noqa: E402
19
21
  find_drift_rows,
20
22
  is_markdown_file,
21
23
  )
24
+ from test_hook_subprocess_support import ( # noqa: E402
25
+ assert_hook_deny_log_contains,
26
+ run_hook_as_subprocess,
27
+ )
28
+
29
+ HOOK_PATH = _BLOCKING_DIRECTORY / "env_var_table_code_drift_blocker.py"
30
+
31
+
32
+ def _run_hook(
33
+ file_path: Path, content: str, temporary_repository_root: Path
34
+ ) -> subprocess.CompletedProcess[str]:
35
+ relative_file_path = file_path.relative_to(temporary_repository_root)
36
+ payload = {
37
+ "tool_name": "Write",
38
+ "tool_input": {"file_path": str(relative_file_path), "content": content},
39
+ }
40
+ return run_hook_as_subprocess(
41
+ hook_script_path=HOOK_PATH,
42
+ payload_text=json.dumps(payload),
43
+ working_directory=temporary_repository_root,
44
+ home_directory=temporary_repository_root,
45
+ )
22
46
 
23
47
 
24
48
  def _write(file_path: Path, content: str) -> None:
@@ -64,6 +88,49 @@ def test_passes_when_variable_present_in_code_file(tmp_path: Path) -> None:
64
88
  assert find_drift_rows(content, doc_path.parent) == []
65
89
 
66
90
 
91
+ def test_production_entrypoint_allows_aligned_table(tmp_path: Path) -> None:
92
+ _anchor_repo_root(tmp_path)
93
+ _write(
94
+ tmp_path / "automation_logging" / "__init__.py",
95
+ 'topic = os.environ["NTFY_TOPIC"]\n',
96
+ )
97
+ doc_path = tmp_path / "docs" / "configuration.md"
98
+ doc_path.parent.mkdir()
99
+ content = (
100
+ "| Variable | Used By | Purpose |\n"
101
+ "|----------|---------|---------|\n"
102
+ "| `NTFY_TOPIC` | `automation_logging/__init__.py` | ntfy topic |\n"
103
+ )
104
+
105
+ completed = _run_hook(doc_path, content, tmp_path)
106
+
107
+ assert completed.returncode == 0, completed.stderr
108
+ assert completed.stdout == ""
109
+
110
+
111
+ def test_production_entrypoint_denies_drifted_table(tmp_path: Path) -> None:
112
+ _anchor_repo_root(tmp_path)
113
+ _write(tmp_path / "auth" / "google_auth.py", "def load():\n return read_bws_secret()\n")
114
+ doc_path = tmp_path / "docs" / "configuration.md"
115
+ doc_path.parent.mkdir()
116
+ content = (
117
+ "| Variable | Used By | Purpose |\n"
118
+ "|----------|---------|---------|\n"
119
+ "| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | Path to JSON |\n"
120
+ )
121
+
122
+ completed = _run_hook(doc_path, content, tmp_path)
123
+
124
+ parsed_payload = json.loads(completed.stdout)
125
+ hook_specific_output = parsed_payload["hookSpecificOutput"]
126
+ assert completed.returncode == 0, completed.stderr
127
+ assert hook_specific_output["permissionDecision"] == "deny"
128
+ assert "GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py" in hook_specific_output[
129
+ "permissionDecisionReason"
130
+ ]
131
+ assert_hook_deny_log_contains(tmp_path, "env_var_table_code_drift_blocker.py")
132
+
133
+
67
134
  def test_skips_row_whose_code_file_is_absent(tmp_path: Path) -> None:
68
135
  _anchor_repo_root(tmp_path)
69
136
  doc_path = tmp_path / "docs" / "configuration.md"