claude-dev-env 2.17.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/AGENTS.md +4 -4
- package/.agents/skills/_shared/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +89 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/orchestrator/SKILL.md +6 -4
- package/.agents/skills/orchestrator-refresh/SKILL.md +9 -5
- package/.agents/skills/pr-cleanup/SKILL.md +17 -85
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
- package/.agents/skills/pr-refinement/SKILL.md +31 -0
- package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
- package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
- package/.agents/skills/skill-builder/SKILL.md +1 -0
- package/.agents/skills/team-advisor/SKILL.md +5 -5
- package/.agents/skills-archived/bugteam/SKILL.md +1 -1
- package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
- package/.agents/skills-archived/comments/SKILL.md +1 -1
- package/.agents/skills-archived/descriptions/SKILL.md +1 -1
- package/.agents/skills-archived/reviews/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/_shared/advisor/AGENTS.md +5 -5
- package/_shared/advisor/advisor-protocol.md +54 -31
- package/_shared/advisor/reference/advisor-block.md +5 -1
- package/_shared/advisor/reference/consult-format.md +1 -1
- package/_shared/advisor/reference/identity.md +28 -0
- package/_shared/advisor/reference/lifecycle.md +8 -1
- package/_shared/advisor/reference/sol-rung.md +12 -7
- package/_shared/advisor/reference/spawn-walk-log.md +6 -5
- package/_shared/advisor/reference/third-party-bind.md +5 -6
- package/_shared/advisor/reference/warm-up.md +9 -2
- package/_shared/advisor/scripts/codex_sol_advisor.py +66 -11
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +9 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -4
- package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +11 -10
- package/_shared/advisor/scripts/model_tier_run_validator.py +50 -23
- package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +108 -3
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +171 -82
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +43 -0
- package/_shared/advisor/scripts/tier_model_ids.py +75 -7
- package/_shared/pr-loop/AGENTS.md +2 -1
- package/_shared/pr-loop/preflight-proposal.md +56 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
- package/_shared/pr-loop/worker-spawn.md +4 -3
- package/bin/ever-shipped-skills.mjs +14 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +23 -0
- package/bin/install.codex-rules.test.mjs +61 -6
- package/bin/install.mjs +57 -9
- package/bin/install.prune.test.mjs +65 -3
- package/bin/install.test.mjs +202 -2
- package/docs/CODE_RULES.md +4 -0
- package/docs/references/team-advisor-skill.md +2 -2
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
- package/hooks/blocking/destructive_command_blocker.py +8 -7
- package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
- package/hooks/blocking/precommit_code_rules_gate.py +14 -139
- package/hooks/blocking/sensitive_file_protector.py +8 -6
- package/hooks/blocking/session_edit_stage_gate.py +31 -1
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
- package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
- package/hooks/blocking/test_hook_subprocess_support.py +296 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
- package/hooks/blocking/test_pii_scanner.py +81 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
- package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
- package/hooks/blocking/test_sensitive_file_protector.py +145 -5
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
- package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
- package/hooks/blocking/test_test_preflight_check.py +80 -0
- package/hooks/git-hooks/pre_commit.py +6 -2
- package/hooks/git-hooks/test_pre_push.py +57 -0
- package/hooks/hooks_constants/AGENTS.md +3 -1
- package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
- package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
- package/hooks/json_file_reader.py +22 -0
- package/hooks/pending_sidecars.py +33 -0
- package/hooks/test_atomic_file_writer.py +73 -0
- package/hooks/test_json_file_reader.py +24 -0
- package/hooks/test_pending_sidecars.py +25 -0
- package/package.json +1 -1
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared entrypoint helpers for code-rules enforcer behavior tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_write_payload(file_path: str, content: str) -> str:
|
|
18
|
+
"""Build the JSON payload sent by a Write tool call."""
|
|
19
|
+
return json.dumps(
|
|
20
|
+
{
|
|
21
|
+
"tool_name": "Write",
|
|
22
|
+
"tool_input": {"file_path": file_path, "content": content},
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_serialized_payload_entrypoint(
|
|
28
|
+
entrypoint: Callable[[list[str]], None],
|
|
29
|
+
serialized_payload: str,
|
|
30
|
+
) -> tuple[str, int | None]:
|
|
31
|
+
"""Run a callable hook entrypoint with a serialized stdin payload."""
|
|
32
|
+
input_stream = io.StringIO(serialized_payload)
|
|
33
|
+
captured_stream = io.StringIO()
|
|
34
|
+
original_stdin = sys.stdin
|
|
35
|
+
original_stdout = sys.stdout
|
|
36
|
+
exit_code: int | None = None
|
|
37
|
+
try:
|
|
38
|
+
sys.stdin = input_stream
|
|
39
|
+
sys.stdout = captured_stream
|
|
40
|
+
entrypoint([])
|
|
41
|
+
except SystemExit as each_exit:
|
|
42
|
+
raw_code = each_exit.code
|
|
43
|
+
exit_code = raw_code if isinstance(raw_code, int) else None
|
|
44
|
+
finally:
|
|
45
|
+
sys.stdin = original_stdin
|
|
46
|
+
sys.stdout = original_stdout
|
|
47
|
+
return captured_stream.getvalue(), exit_code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_write_entrypoint(
|
|
51
|
+
entrypoint: Callable[[list[str]], None],
|
|
52
|
+
file_path: str,
|
|
53
|
+
content: str,
|
|
54
|
+
) -> tuple[str, int]:
|
|
55
|
+
"""Drive a real hook entrypoint with a Write payload and capture stdout."""
|
|
56
|
+
captured_stdout, exit_code = run_serialized_payload_entrypoint(
|
|
57
|
+
entrypoint, build_write_payload(file_path, content)
|
|
58
|
+
)
|
|
59
|
+
return captured_stdout, int(exit_code or 0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_enforcer_cli(
|
|
63
|
+
script_path: Path,
|
|
64
|
+
all_arguments: list[str],
|
|
65
|
+
extra_environment_by_name: dict[str, str] | None,
|
|
66
|
+
) -> subprocess.CompletedProcess[str]:
|
|
67
|
+
"""Run an enforcer script through its real command-line entrypoint."""
|
|
68
|
+
subprocess_environment: dict[str, str] | None = None
|
|
69
|
+
if extra_environment_by_name is not None:
|
|
70
|
+
subprocess_environment = os.environ.copy()
|
|
71
|
+
subprocess_environment.update(extra_environment_by_name)
|
|
72
|
+
return subprocess.run(
|
|
73
|
+
[sys.executable, str(script_path), *all_arguments],
|
|
74
|
+
stdin=subprocess.DEVNULL,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
check=False,
|
|
78
|
+
env=subprocess_environment,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_precheck(
|
|
83
|
+
candidate_path: Path,
|
|
84
|
+
target_path: str,
|
|
85
|
+
extra_environment_by_name: dict[str, str] | None,
|
|
86
|
+
) -> subprocess.CompletedProcess[str]:
|
|
87
|
+
"""Run an enforcer script through its real precheck command."""
|
|
88
|
+
return run_enforcer_cli(
|
|
89
|
+
_ENFORCER_SCRIPT_PATH,
|
|
90
|
+
["--check", str(candidate_path), "--as", target_path],
|
|
91
|
+
extra_environment_by_name,
|
|
92
|
+
)
|
|
@@ -20,12 +20,16 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
|
|
|
20
20
|
CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
|
|
21
21
|
)
|
|
22
22
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
23
|
-
from hooks_constants.
|
|
24
|
-
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
|
|
25
|
-
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
|
|
23
|
+
from hooks_constants.destructive_command_environment_constants import ( # noqa: E402
|
|
26
24
|
ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES,
|
|
27
25
|
ALL_TRUTHY_ENV_VALUES,
|
|
28
26
|
DESTRUCTIVE_DENY_MODE_ENV_VAR,
|
|
27
|
+
EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
|
|
28
|
+
GH_REDIRECT_ACTIVE_ENV_VAR,
|
|
29
|
+
)
|
|
30
|
+
from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
|
|
31
|
+
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
|
|
32
|
+
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
|
|
29
33
|
ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM,
|
|
30
34
|
ALL_FIND_EXEC_ACTION_FLAGS,
|
|
31
35
|
ALL_FIND_EXEC_ACTION_TERMINATORS,
|
|
@@ -57,7 +61,6 @@ from hooks_constants.destructive_command_segment_constants import ( # noqa: E40
|
|
|
57
61
|
)
|
|
58
62
|
|
|
59
63
|
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
60
|
-
GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
|
|
61
64
|
|
|
62
65
|
|
|
63
66
|
def gh_redirect_is_active() -> bool:
|
|
@@ -87,9 +90,7 @@ def directory_is_ephemeral(directory_path: str) -> bool:
|
|
|
87
90
|
Returns:
|
|
88
91
|
True when the directory belongs to the ephemeral auto-allow namespace.
|
|
89
92
|
"""
|
|
90
|
-
|
|
91
|
-
truthy_string_values = frozenset({"1", "true", "yes", "on"})
|
|
92
|
-
if os.environ.get(ephemeral_auto_allow_disabled_env_var, "").strip().lower() in truthy_string_values:
|
|
93
|
+
if os.environ.get(EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR, "").strip().lower() in ALL_TRUTHY_ENV_VALUES:
|
|
93
94
|
return False
|
|
94
95
|
forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
|
|
95
96
|
all_worktree_path_segments = ("/worktrees/", "/worktree/")
|
|
@@ -486,16 +486,20 @@ def _emit_allow_decision(decision: DispatcherDecision) -> None:
|
|
|
486
486
|
|
|
487
487
|
Matches the shape a standalone hosted hook emits when it auto-approves the
|
|
488
488
|
write, so a write a hosted hook allows explicitly is auto-approved under the
|
|
489
|
-
dispatcher rather than falling back to the default permission flow.
|
|
490
|
-
|
|
491
|
-
that notice rides on the allow payload without denying.
|
|
489
|
+
dispatcher rather than falling back to the default permission flow. Advisory
|
|
490
|
+
systemMessage and additionalContext fields ride on the allow payload.
|
|
492
491
|
"""
|
|
492
|
+
allow_hook_specific: dict[str, object] = {
|
|
493
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
494
|
+
"permissionDecision": ALLOW_DECISION,
|
|
495
|
+
}
|
|
493
496
|
allow_payload: dict[str, object] = {
|
|
494
|
-
"hookSpecificOutput":
|
|
495
|
-
"hookEventName": HOOK_EVENT_NAME,
|
|
496
|
-
"permissionDecision": ALLOW_DECISION,
|
|
497
|
-
}
|
|
497
|
+
"hookSpecificOutput": allow_hook_specific,
|
|
498
498
|
}
|
|
499
|
+
if decision.all_additional_context:
|
|
500
|
+
allow_hook_specific["additionalContext"] = CONTEXT_JOIN_SEPARATOR.join(
|
|
501
|
+
decision.all_additional_context
|
|
502
|
+
)
|
|
499
503
|
if decision.all_system_messages:
|
|
500
504
|
allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
|
|
501
505
|
decision.all_system_messages
|
|
@@ -1,40 +1,26 @@
|
|
|
1
|
-
"""PreToolUse hook that
|
|
1
|
+
"""PreToolUse hook that passes Git commit validation to Git hooks.
|
|
2
2
|
|
|
3
3
|
Intercepts Bash `git commit` invocations (including `git -C <path> commit`),
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
CODE_RULES violations is denied with the gate's file:line report so the
|
|
7
|
-
violations surface before the commit instead of stalling converge loops at
|
|
8
|
-
commit time. Non-commit commands, repositories with no staged Python files,
|
|
9
|
-
and clean staged changes pass through silently. A gate-engine failure denies
|
|
10
|
-
the commit with the failure detail — the gate never fails open.
|
|
4
|
+
emits neutral evidence, and returns control to Git. Configured Git hooks
|
|
5
|
+
receive the commit at the commit boundary.
|
|
11
6
|
"""
|
|
12
7
|
|
|
13
|
-
import json
|
|
14
8
|
import re
|
|
15
9
|
import subprocess
|
|
16
10
|
import sys
|
|
17
11
|
from pathlib import Path
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
if
|
|
21
|
-
sys.path.insert(0,
|
|
22
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
23
|
-
if _hooks_dir not in sys.path:
|
|
24
|
-
sys.path.insert(0, _hooks_dir)
|
|
13
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
if _hooks_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_directory)
|
|
25
16
|
|
|
26
17
|
from block_main_commit import ( # noqa: E402
|
|
27
|
-
extract_git_working_directory,
|
|
28
18
|
is_commit_command,
|
|
29
19
|
parse_bash_command_from_stdin,
|
|
30
|
-
resolve_directory,
|
|
31
20
|
)
|
|
32
|
-
|
|
21
|
+
|
|
33
22
|
from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
|
|
34
23
|
ALL_GIT_REPOSITORY_ROOT_COMMAND,
|
|
35
|
-
ALL_STAGED_PYTHON_FILES_COMMAND,
|
|
36
|
-
GATE_RELATIVE_PATH,
|
|
37
|
-
GATE_TIMEOUT_SECONDS,
|
|
38
24
|
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
39
25
|
GIT_DASH_C_COMMIT_PATTERN,
|
|
40
26
|
)
|
|
@@ -59,20 +45,19 @@ def is_git_commit_invocation(bash_command: str) -> bool:
|
|
|
59
45
|
|
|
60
46
|
|
|
61
47
|
def resolve_repository_root(working_directory: str | None) -> Path | None:
|
|
62
|
-
"""Resolve the
|
|
48
|
+
"""Resolve the Git repository root for a directory.
|
|
63
49
|
|
|
64
50
|
Args:
|
|
65
|
-
working_directory: Directory the
|
|
66
|
-
hook's current working directory.
|
|
51
|
+
working_directory: Directory inside the repository.
|
|
67
52
|
|
|
68
53
|
Returns:
|
|
69
|
-
|
|
70
|
-
a git repository or git is unavailable.
|
|
54
|
+
Repository root. Returns None when Git cannot resolve the directory.
|
|
71
55
|
"""
|
|
72
56
|
try:
|
|
73
57
|
completed_process = subprocess.run(
|
|
74
58
|
list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
|
|
75
|
-
check=False,
|
|
59
|
+
check=False,
|
|
60
|
+
capture_output=True,
|
|
76
61
|
text=True,
|
|
77
62
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
78
63
|
cwd=working_directory,
|
|
@@ -87,122 +72,12 @@ def resolve_repository_root(working_directory: str | None) -> Path | None:
|
|
|
87
72
|
return Path(top_level_text)
|
|
88
73
|
|
|
89
74
|
|
|
90
|
-
def list_staged_python_files(repository_root: Path) -> list[str]:
|
|
91
|
-
"""List repository-relative paths of staged Python files.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
repository_root: Repository root used as the git working directory.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
Repository-relative paths of Python files staged for add, copy,
|
|
98
|
-
modify, or rename. Empty when the listing command fails — the
|
|
99
|
-
caller then allows the commit because git itself will surface the
|
|
100
|
-
repository problem.
|
|
101
|
-
"""
|
|
102
|
-
try:
|
|
103
|
-
completed_process = subprocess.run(
|
|
104
|
-
list(ALL_STAGED_PYTHON_FILES_COMMAND),
|
|
105
|
-
check=False, capture_output=True,
|
|
106
|
-
text=True,
|
|
107
|
-
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
108
|
-
cwd=str(repository_root),
|
|
109
|
-
)
|
|
110
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
111
|
-
return []
|
|
112
|
-
if completed_process.returncode != 0:
|
|
113
|
-
return []
|
|
114
|
-
return [
|
|
115
|
-
each_line.strip()
|
|
116
|
-
for each_line in completed_process.stdout.splitlines()
|
|
117
|
-
if each_line.strip()
|
|
118
|
-
]
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def run_staged_gate(repository_root: Path) -> tuple[int, str]:
|
|
122
|
-
"""Run the shared code_rules_gate engine in staged mode.
|
|
123
|
-
|
|
124
|
-
Args:
|
|
125
|
-
repository_root: Repository root passed to the gate's --repo-root
|
|
126
|
-
and used as the gate subprocess working directory, so relative
|
|
127
|
-
paths the gate's checks resolve land in the committing worktree
|
|
128
|
-
rather than the hook's own working directory.
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
Tuple of the gate exit code and its stderr report. A missing gate
|
|
132
|
-
script or a gate timeout returns a non-zero code with an
|
|
133
|
-
explanatory message so the commit is denied rather than waved
|
|
134
|
-
through on infrastructure failure.
|
|
135
|
-
"""
|
|
136
|
-
gate_path = Path(__file__).resolve().parents[2] / GATE_RELATIVE_PATH
|
|
137
|
-
if not gate_path.is_file():
|
|
138
|
-
return 1, f"precommit_code_rules_gate: gate engine missing at {gate_path}"
|
|
139
|
-
try:
|
|
140
|
-
completed_process = subprocess.run(
|
|
141
|
-
[
|
|
142
|
-
sys.executable,
|
|
143
|
-
str(gate_path),
|
|
144
|
-
"--repo-root",
|
|
145
|
-
str(repository_root),
|
|
146
|
-
"--staged",
|
|
147
|
-
],
|
|
148
|
-
check=False, capture_output=True,
|
|
149
|
-
text=True,
|
|
150
|
-
encoding="utf-8",
|
|
151
|
-
errors="replace",
|
|
152
|
-
timeout=GATE_TIMEOUT_SECONDS,
|
|
153
|
-
cwd=str(repository_root),
|
|
154
|
-
)
|
|
155
|
-
except subprocess.TimeoutExpired:
|
|
156
|
-
return 1, (
|
|
157
|
-
f"precommit_code_rules_gate: gate engine timed out after {GATE_TIMEOUT_SECONDS}s"
|
|
158
|
-
)
|
|
159
|
-
return completed_process.returncode, completed_process.stderr
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def build_denial_response(gate_report: str) -> dict:
|
|
163
|
-
"""Build the PreToolUse deny payload carrying the gate report.
|
|
164
|
-
|
|
165
|
-
Args:
|
|
166
|
-
gate_report: The gate's stderr report listing file:line violations.
|
|
167
|
-
|
|
168
|
-
Returns:
|
|
169
|
-
The hookSpecificOutput deny mapping for the PreToolUse protocol.
|
|
170
|
-
"""
|
|
171
|
-
denial_reason = (
|
|
172
|
-
f"BLOCKED: staged files violate CODE_RULES; fix before committing.\n{gate_report.strip()}"
|
|
173
|
-
)
|
|
174
|
-
return {
|
|
175
|
-
"hookSpecificOutput": {
|
|
176
|
-
"hookEventName": "PreToolUse",
|
|
177
|
-
"permissionDecision": "deny",
|
|
178
|
-
"permissionDecisionReason": denial_reason,
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
75
|
def main() -> None:
|
|
184
|
-
"""
|
|
76
|
+
"""Pass Git commit validation through to configured Git hooks."""
|
|
185
77
|
bash_command = parse_bash_command_from_stdin()
|
|
186
78
|
if not is_git_commit_invocation(bash_command):
|
|
187
79
|
sys.exit(0)
|
|
188
|
-
|
|
189
|
-
repository_root = resolve_repository_root(working_directory)
|
|
190
|
-
if repository_root is None:
|
|
191
|
-
sys.exit(0)
|
|
192
|
-
if not list_staged_python_files(repository_root):
|
|
193
|
-
sys.exit(0)
|
|
194
|
-
gate_exit_code, gate_report = run_staged_gate(repository_root)
|
|
195
|
-
if gate_exit_code == 0:
|
|
196
|
-
sys.exit(0)
|
|
197
|
-
denial = build_denial_response(gate_report)
|
|
198
|
-
log_hook_block(
|
|
199
|
-
calling_hook_name="precommit_code_rules_gate.py",
|
|
200
|
-
hook_event="PreToolUse",
|
|
201
|
-
block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
|
|
202
|
-
tool_name="Bash",
|
|
203
|
-
offending_input_preview=bash_command,
|
|
204
|
-
)
|
|
205
|
-
print(json.dumps(denial))
|
|
80
|
+
sys.stderr.write("Git commit proceeds to configured Git hooks.\n")
|
|
206
81
|
sys.exit(0)
|
|
207
82
|
|
|
208
83
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Deny a Write or
|
|
2
|
+
"""Deny a Write, Edit, or MultiEdit whose target filename names a secret or a lock file.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
@@ -35,10 +35,12 @@ import sys
|
|
|
35
35
|
import _path_setup # noqa: F401
|
|
36
36
|
|
|
37
37
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
38
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
39
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
40
|
+
)
|
|
38
41
|
from hooks_constants.sensitive_file_protector_constants import (
|
|
39
42
|
ALL_SENSITIVE_PATTERNS,
|
|
40
43
|
ALL_TEMPLATE_SUFFIXES,
|
|
41
|
-
ALL_WRITE_EDIT_TOOLS,
|
|
42
44
|
DENY_DECISION,
|
|
43
45
|
DENY_REASON_TEMPLATE,
|
|
44
46
|
HOOK_EVENT_NAME,
|
|
@@ -57,7 +59,7 @@ def is_template_filename(filename: str) -> bool:
|
|
|
57
59
|
.env.local -> False
|
|
58
60
|
|
|
59
61
|
Args:
|
|
60
|
-
filename: The basename of the file a Write or
|
|
62
|
+
filename: The basename of the file a Write, Edit, or MultiEdit targets.
|
|
61
63
|
|
|
62
64
|
Returns:
|
|
63
65
|
True when the basename's final suffix is a template suffix.
|
|
@@ -69,7 +71,7 @@ def is_sensitive_file(file_path: str) -> str | None:
|
|
|
69
71
|
"""Return the sensitive pattern a path's basename matches, or None.
|
|
70
72
|
|
|
71
73
|
Args:
|
|
72
|
-
file_path: The path the Write or
|
|
74
|
+
file_path: The path the Write, Edit, or MultiEdit targets.
|
|
73
75
|
|
|
74
76
|
Returns:
|
|
75
77
|
The matched pattern, or None when the basename names a template or
|
|
@@ -106,7 +108,7 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
|
|
|
106
108
|
"""Record the block in the hook-blocks log and emit the deny decision.
|
|
107
109
|
|
|
108
110
|
Args:
|
|
109
|
-
file_path: The path the Write or
|
|
111
|
+
file_path: The path the Write, Edit, or MultiEdit targets.
|
|
110
112
|
matched_pattern: The sensitive pattern the basename matched.
|
|
111
113
|
"""
|
|
112
114
|
deny_reason = DENY_REASON_TEMPLATE.format(
|
|
@@ -132,7 +134,7 @@ def main() -> None:
|
|
|
132
134
|
tool_name = hook_input.get("tool_name", "")
|
|
133
135
|
tool_input = hook_input.get("tool_input", {})
|
|
134
136
|
|
|
135
|
-
if tool_name not in
|
|
137
|
+
if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
|
|
136
138
|
sys.exit(0)
|
|
137
139
|
|
|
138
140
|
file_path = tool_input.get("file_path", "")
|
|
@@ -18,6 +18,7 @@ blocks on infrastructure trouble.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import json
|
|
21
|
+
import os
|
|
21
22
|
import shlex
|
|
22
23
|
import subprocess
|
|
23
24
|
import sys
|
|
@@ -601,6 +602,35 @@ def _build_denial(all_offending_paths: list[str]) -> dict:
|
|
|
601
602
|
}
|
|
602
603
|
|
|
603
604
|
|
|
605
|
+
def _resolve_hook_working_directory(
|
|
606
|
+
bash_command: str,
|
|
607
|
+
all_hook_payload: dict[str, object],
|
|
608
|
+
) -> str | None:
|
|
609
|
+
"""Resolve the commit directory from the command or its event payload.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
bash_command: Bash command whose explicit directory takes precedence.
|
|
613
|
+
all_hook_payload: PreToolUse payload carrying the event ``cwd``.
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
The validated commit directory, or None for the hook process directory.
|
|
617
|
+
"""
|
|
618
|
+
command_directory = extract_git_working_directory(bash_command)
|
|
619
|
+
event_directory = all_hook_payload.get("cwd")
|
|
620
|
+
if not isinstance(event_directory, str) or not event_directory:
|
|
621
|
+
return resolve_directory(command_directory)
|
|
622
|
+
validated_event_directory = resolve_directory(event_directory)
|
|
623
|
+
if command_directory is None:
|
|
624
|
+
return validated_event_directory
|
|
625
|
+
expanded_command_directory = os.path.expanduser(command_directory)
|
|
626
|
+
command_path = Path(expanded_command_directory)
|
|
627
|
+
if command_path.is_absolute():
|
|
628
|
+
return resolve_directory(str(command_path))
|
|
629
|
+
if validated_event_directory is None:
|
|
630
|
+
return None
|
|
631
|
+
return resolve_directory(str(Path(validated_event_directory) / command_path))
|
|
632
|
+
|
|
633
|
+
|
|
604
634
|
def main() -> None:
|
|
605
635
|
"""Deny a git commit that would drop a tracked file that was edited yet left unstaged."""
|
|
606
636
|
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
@@ -620,7 +650,7 @@ def main() -> None:
|
|
|
620
650
|
session_edited_keys = _session_edited_keys(session_id)
|
|
621
651
|
if not session_edited_keys:
|
|
622
652
|
sys.exit(0)
|
|
623
|
-
working_directory =
|
|
653
|
+
working_directory = _resolve_hook_working_directory(bash_command, hook_payload)
|
|
624
654
|
repository_root = resolve_repository_root(working_directory)
|
|
625
655
|
if repository_root is None:
|
|
626
656
|
sys.exit(0)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for claude_md_orphan_file_blocker hook."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import importlib
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
@@ -9,7 +10,57 @@ from pathlib import Path
|
|
|
9
10
|
|
|
10
11
|
import pytest
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
_test_file_path = Path(__file__).resolve()
|
|
14
|
+
_blocking_directory = str(_test_file_path.parent)
|
|
15
|
+
_hooks_directory = str(_test_file_path.parent.parent)
|
|
16
|
+
_blocking_directory_key = os.path.normcase(os.path.abspath(_blocking_directory))
|
|
17
|
+
_hooks_directory_key = os.path.normcase(os.path.abspath(_hooks_directory))
|
|
18
|
+
_hooks_directory_origin_key = _hooks_directory_key
|
|
19
|
+
sys.path[:] = [_blocking_directory, _hooks_directory] + [
|
|
20
|
+
each_path
|
|
21
|
+
for each_path in sys.path
|
|
22
|
+
if not isinstance(each_path, str)
|
|
23
|
+
or os.path.normcase(os.path.abspath(each_path))
|
|
24
|
+
not in (_blocking_directory_key, _hooks_directory_key)
|
|
25
|
+
]
|
|
26
|
+
importlib.invalidate_caches()
|
|
27
|
+
|
|
28
|
+
_all_cached_module_prefixes = (
|
|
29
|
+
"claude_md_orphan_file_blocker",
|
|
30
|
+
"claude_md_orphan_file_blocker_parts",
|
|
31
|
+
"inventory_intent_records",
|
|
32
|
+
"hooks_constants",
|
|
33
|
+
"code_rules_annotations_length",
|
|
34
|
+
"code_rules_boolean_mustcheck",
|
|
35
|
+
"code_rules_naming_collection",
|
|
36
|
+
"code_rules_path_utils",
|
|
37
|
+
"code_rules_shared",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
for each_module_name, each_module in tuple(sys.modules.items()):
|
|
42
|
+
if not any(
|
|
43
|
+
each_module_name == each_prefix or each_module_name.startswith(f"{each_prefix}.")
|
|
44
|
+
for each_prefix in _all_cached_module_prefixes
|
|
45
|
+
):
|
|
46
|
+
continue
|
|
47
|
+
module_file = getattr(each_module, "__file__", None)
|
|
48
|
+
is_cached_module_in_this_checkout = False
|
|
49
|
+
if isinstance(module_file, str):
|
|
50
|
+
module_file_key = os.path.normcase(str(Path(module_file).resolve()))
|
|
51
|
+
try:
|
|
52
|
+
is_cached_module_in_this_checkout = (
|
|
53
|
+
os.path.commonpath((_hooks_directory_origin_key, module_file_key))
|
|
54
|
+
== _hooks_directory_origin_key
|
|
55
|
+
)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
if is_cached_module_in_this_checkout:
|
|
59
|
+
continue
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"test_claude_md_orphan_file_blocker: cached module outside this checkout: "
|
|
62
|
+
f"{each_module_name} ({module_file!r})"
|
|
63
|
+
)
|
|
13
64
|
|
|
14
65
|
import claude_md_orphan_file_blocker as blocker_module
|
|
15
66
|
from claude_md_orphan_file_blocker import (
|
|
@@ -82,6 +133,125 @@ def _isolated_claude_md_path(tmp_path: Path) -> Path:
|
|
|
82
133
|
return isolated_directory / "CLAUDE.md"
|
|
83
134
|
|
|
84
135
|
|
|
136
|
+
def _run_isolated_test_module_import(
|
|
137
|
+
driver_setup: str, driver_assertions: str
|
|
138
|
+
) -> subprocess.CompletedProcess:
|
|
139
|
+
"""Import this test module in an isolated interpreter with caller-supplied state."""
|
|
140
|
+
test_module_path = _test_file_path
|
|
141
|
+
driver = (
|
|
142
|
+
"import importlib.util\n"
|
|
143
|
+
"import os\n"
|
|
144
|
+
"import site\n"
|
|
145
|
+
"import sys\n"
|
|
146
|
+
"from pathlib import Path\n"
|
|
147
|
+
f"test_module_path = Path({str(test_module_path)!r})\n"
|
|
148
|
+
"sys.path.append(site.getusersitepackages())\n"
|
|
149
|
+
f"{driver_setup}"
|
|
150
|
+
"spec = importlib.util.spec_from_file_location('bootstrap_target_test', test_module_path)\n"
|
|
151
|
+
"module = importlib.util.module_from_spec(spec)\n"
|
|
152
|
+
"spec.loader.exec_module(module)\n"
|
|
153
|
+
f"{driver_assertions}"
|
|
154
|
+
)
|
|
155
|
+
return subprocess.run(
|
|
156
|
+
[sys.executable, "-I", "-c", driver],
|
|
157
|
+
check=False,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_import_bootstrap_prioritizes_this_checkout(tmp_path: Path) -> None:
|
|
164
|
+
foreign_hooks_directory = tmp_path / "foreign_hooks"
|
|
165
|
+
foreign_blocking_directory = foreign_hooks_directory / "blocking"
|
|
166
|
+
foreign_constants_directory = foreign_hooks_directory / "hooks_constants"
|
|
167
|
+
foreign_blocking_directory.mkdir(parents=True)
|
|
168
|
+
foreign_constants_directory.mkdir()
|
|
169
|
+
(foreign_blocking_directory / "claude_md_orphan_file_blocker.py").write_text(
|
|
170
|
+
"raise RuntimeError('foreign blocker loaded')\n", encoding="utf-8"
|
|
171
|
+
)
|
|
172
|
+
(foreign_constants_directory / "__init__.py").write_text(
|
|
173
|
+
"raise RuntimeError('foreign constants loaded')\n", encoding="utf-8"
|
|
174
|
+
)
|
|
175
|
+
target_blocking_directory = Path(__file__).resolve().parent
|
|
176
|
+
target_hooks_directory = target_blocking_directory.parent
|
|
177
|
+
driver_setup = (
|
|
178
|
+
f"foreign_blocking_directory = Path({str(foreign_blocking_directory)!r})\n"
|
|
179
|
+
f"foreign_hooks_directory = Path({str(foreign_hooks_directory)!r})\n"
|
|
180
|
+
f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
|
|
181
|
+
f"target_hooks_directory = Path({str(target_hooks_directory)!r})\n"
|
|
182
|
+
"sys.path[:0] = [\n"
|
|
183
|
+
" str(foreign_blocking_directory), str(foreign_hooks_directory),\n"
|
|
184
|
+
" str(target_blocking_directory), str(target_hooks_directory),\n"
|
|
185
|
+
"]\n"
|
|
186
|
+
)
|
|
187
|
+
driver_assertions = (
|
|
188
|
+
"import claude_md_orphan_file_blocker\n"
|
|
189
|
+
"import hooks_constants\n"
|
|
190
|
+
"assert Path(claude_md_orphan_file_blocker.__file__).resolve().parent "
|
|
191
|
+
"== target_blocking_directory\n"
|
|
192
|
+
"assert Path(hooks_constants.__file__).resolve().parent.parent "
|
|
193
|
+
"== target_hooks_directory\n"
|
|
194
|
+
"all_uncovered_module_names = [\n"
|
|
195
|
+
" each_module_name\n"
|
|
196
|
+
" for each_module_name, each_loaded_module in sys.modules.items()\n"
|
|
197
|
+
" if each_loaded_module is not module\n"
|
|
198
|
+
" and isinstance(getattr(each_loaded_module, '__file__', None), str)\n"
|
|
199
|
+
" and Path(each_loaded_module.__file__).resolve().is_relative_to(\n"
|
|
200
|
+
" target_hooks_directory\n"
|
|
201
|
+
" )\n"
|
|
202
|
+
" and not any(\n"
|
|
203
|
+
" each_module_name == each_prefix\n"
|
|
204
|
+
" or each_module_name.startswith(f'{each_prefix}.')\n"
|
|
205
|
+
" for each_prefix in module._all_cached_module_prefixes\n"
|
|
206
|
+
" )\n"
|
|
207
|
+
"]\n"
|
|
208
|
+
"assert not all_uncovered_module_names, all_uncovered_module_names\n"
|
|
209
|
+
)
|
|
210
|
+
completed = _run_isolated_test_module_import(driver_setup, driver_assertions)
|
|
211
|
+
assert completed.returncode == 0, completed.stderr
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_import_bootstrap_preserves_non_string_path_entries() -> None:
|
|
215
|
+
completed = _run_isolated_test_module_import(
|
|
216
|
+
"sys.path.insert(0, None)\n", "assert None in sys.path\n"
|
|
217
|
+
)
|
|
218
|
+
assert completed.returncode == 0, completed.stderr
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_import_bootstrap_invalidates_stale_importer_cache() -> None:
|
|
222
|
+
target_blocking_directory = Path(__file__).resolve().parent
|
|
223
|
+
driver_setup = (
|
|
224
|
+
f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
|
|
225
|
+
"sys.path_importer_cache[str(target_blocking_directory)] = None\n"
|
|
226
|
+
)
|
|
227
|
+
completed = _run_isolated_test_module_import(driver_setup, "")
|
|
228
|
+
assert completed.returncode == 0, completed.stderr
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.parametrize(
|
|
232
|
+
"cached_module_name",
|
|
233
|
+
(
|
|
234
|
+
"hooks_constants",
|
|
235
|
+
"claude_md_orphan_file_blocker",
|
|
236
|
+
"code_rules_path_utils",
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
def test_import_bootstrap_rejects_foreign_cached_modules(
|
|
240
|
+
tmp_path: Path, cached_module_name: str
|
|
241
|
+
) -> None:
|
|
242
|
+
foreign_module_path = tmp_path / "foreign_hooks" / f"{cached_module_name}.py"
|
|
243
|
+
driver_setup = (
|
|
244
|
+
"import types\n"
|
|
245
|
+
f"cached_module_name = {cached_module_name!r}\n"
|
|
246
|
+
"cached_module = types.ModuleType(cached_module_name)\n"
|
|
247
|
+
f"cached_module.__file__ = {str(foreign_module_path)!r}\n"
|
|
248
|
+
"sys.modules[cached_module_name] = cached_module\n"
|
|
249
|
+
)
|
|
250
|
+
completed = _run_isolated_test_module_import(driver_setup, "")
|
|
251
|
+
assert completed.returncode != 0
|
|
252
|
+
assert f"cached module outside this checkout: {cached_module_name}" in completed.stderr
|
|
253
|
+
|
|
254
|
+
|
|
85
255
|
def test_blocks_write_naming_absent_python_file(tmp_path: Path):
|
|
86
256
|
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
87
257
|
result = _run_hook(
|