claude-dev-env 1.26.4 → 1.27.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/bin/git_hooks_installer.mjs +245 -0
- package/bin/git_hooks_installer.test.mjs +208 -0
- package/bin/install.mjs +68 -1
- package/hooks/blocking/destructive_command_blocker.py +63 -10
- package/hooks/blocking/hedging_language_blocker.py +18 -9
- package/hooks/blocking/tdd_enforcer.py +52 -6
- package/hooks/blocking/test_destructive_command_blocker.py +169 -0
- package/hooks/blocking/test_hedging_language_blocker.py +135 -0
- package/hooks/blocking/test_tdd_enforcer.py +126 -3
- package/hooks/config/__init__.py +1 -0
- package/hooks/config/messages.py +4 -0
- package/hooks/config/test_messages.py +13 -0
- package/hooks/git-hooks/config.py +48 -0
- package/hooks/git-hooks/gate_utils.py +86 -0
- package/hooks/git-hooks/pre_commit.py +61 -0
- package/hooks/git-hooks/pre_push.py +146 -0
- package/hooks/git-hooks/test_config.py +24 -0
- package/hooks/git-hooks/test_gate_utils.py +225 -0
- package/hooks/git-hooks/test_pre_commit.py +179 -0
- package/hooks/git-hooks/test_pre_push.py +316 -0
- package/hooks/hooks.json +0 -5
- package/package.json +4 -1
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +150 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +271 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Constants for the claude-dev-env git-hook entry points.
|
|
2
|
+
|
|
3
|
+
Co-located with ``pre_commit.py`` and ``pre_push.py`` so the installed shim
|
|
4
|
+
directory is self-contained at runtime: the shim prepends its own directory
|
|
5
|
+
to ``sys.path`` before importing the hook module, which makes ``from config
|
|
6
|
+
import ...`` resolve against this file both inside the repo and under
|
|
7
|
+
``~/.claude/hooks/git-hooks/`` after installation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
STAGED_SCOPE_ARGUMENT: str = "--staged"
|
|
14
|
+
BASE_REFERENCE_ARGUMENT: str = "--base"
|
|
15
|
+
DEFAULT_REMOTE_BASE_REFERENCE: str = "origin/HEAD"
|
|
16
|
+
ALL_ZEROS_OBJECT_NAME_CHARACTER: str = "0"
|
|
17
|
+
STDIN_LINE_FIELD_COUNT: int = 4
|
|
18
|
+
STDIN_REMOTE_OBJECT_FIELD_INDEX: int = 3
|
|
19
|
+
GATE_PATH_OVERRIDE_ENV_VAR: str = "CODE_RULES_GATE_PATH"
|
|
20
|
+
CLAUDE_HOME_ENV_VAR: str = "CLAUDE_HOME"
|
|
21
|
+
CLAUDE_HOME_DEFAULT_SUBDIRECTORY: str = ".claude"
|
|
22
|
+
GATE_SCRIPT_RELATIVE_PATH: tuple[str, ...] = (
|
|
23
|
+
"skills",
|
|
24
|
+
"bugteam",
|
|
25
|
+
"scripts",
|
|
26
|
+
"bugteam_code_rules_gate.py",
|
|
27
|
+
)
|
|
28
|
+
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE: int = 2
|
|
29
|
+
GATE_SCRIPT_NOT_FOUND_MESSAGE: str = (
|
|
30
|
+
"claude-dev-env pre-commit: gate script not found at {path}, skipping enforcement"
|
|
31
|
+
)
|
|
32
|
+
PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE: str = (
|
|
33
|
+
"claude-dev-env pre-push: gate script not found at {path}, skipping enforcement"
|
|
34
|
+
)
|
|
35
|
+
STDIN_READ_FAILURE_MESSAGE: str = (
|
|
36
|
+
"claude-dev-env pre-push: could not read stdin ({error}), aborting"
|
|
37
|
+
)
|
|
38
|
+
INVOKE_GATE_FAILURE_MESSAGE: str = (
|
|
39
|
+
"claude-dev-env: could not launch gate script ({error}), aborting"
|
|
40
|
+
)
|
|
41
|
+
MALFORMED_STDIN_LINE_MESSAGE: str = (
|
|
42
|
+
"claude-dev-env pre-push: ignoring malformed stdin line: {line!r}"
|
|
43
|
+
)
|
|
44
|
+
LOCAL_SHA_FIELD_INDEX: int = 1
|
|
45
|
+
NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
|
|
46
|
+
"claude-dev-env pre-push: no parseable stdin lines; aborting"
|
|
47
|
+
)
|
|
48
|
+
NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Shared utilities for the claude-dev-env git-hook entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from config import (
|
|
10
|
+
CLAUDE_HOME_DEFAULT_SUBDIRECTORY,
|
|
11
|
+
CLAUDE_HOME_ENV_VAR,
|
|
12
|
+
GATE_PATH_OVERRIDE_ENV_VAR,
|
|
13
|
+
GATE_SCRIPT_RELATIVE_PATH,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_gate_script_path() -> tuple[Path, Path | None]:
|
|
18
|
+
"""Return (gate_path, exact_allowed_override_or_none).
|
|
19
|
+
|
|
20
|
+
When CODE_RULES_GATE_PATH is set the second element is the resolved
|
|
21
|
+
override path — the only path is_safe_regular_file will accept.
|
|
22
|
+
When falling back to CLAUDE_HOME / default the second element is None,
|
|
23
|
+
signalling that the trusted prefix (Path.home() / '.claude') applies.
|
|
24
|
+
|
|
25
|
+
Capturing both values here eliminates the TOCTOU window that would arise
|
|
26
|
+
when the same env vars are read again inside is_safe_regular_file.
|
|
27
|
+
"""
|
|
28
|
+
override_path_raw = os.environ.get(GATE_PATH_OVERRIDE_ENV_VAR, "").strip()
|
|
29
|
+
if override_path_raw:
|
|
30
|
+
exact_override = Path(override_path_raw).resolve()
|
|
31
|
+
return exact_override, exact_override
|
|
32
|
+
claude_home_override = os.environ.get(CLAUDE_HOME_ENV_VAR, "").strip()
|
|
33
|
+
if claude_home_override:
|
|
34
|
+
claude_home_directory = Path(claude_home_override).resolve()
|
|
35
|
+
else:
|
|
36
|
+
claude_home_directory = Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY
|
|
37
|
+
gate_path = claude_home_directory.joinpath(*GATE_SCRIPT_RELATIVE_PATH)
|
|
38
|
+
return gate_path, None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_safe_regular_file(candidate_path: Path, exact_allowed_path: Path | None) -> bool:
|
|
42
|
+
"""Return True only when candidate_path is a regular file at a trusted location.
|
|
43
|
+
|
|
44
|
+
When exact_allowed_path is not None candidate_path must resolve to that
|
|
45
|
+
exact path (CODE_RULES_GATE_PATH override case). When it is None the
|
|
46
|
+
resolved candidate must fall within Path.home() / '.claude' — CLAUDE_HOME
|
|
47
|
+
is intentionally excluded from the trust boundary because it is
|
|
48
|
+
attacker-settable via the process environment.
|
|
49
|
+
|
|
50
|
+
The candidate is resolved to its real path before any containment check,
|
|
51
|
+
preventing symlinks inside the trusted tree from redirecting execution
|
|
52
|
+
outside it.
|
|
53
|
+
"""
|
|
54
|
+
resolved_candidate = candidate_path.resolve()
|
|
55
|
+
if not _is_resolved_candidate_allowed(resolved_candidate, exact_allowed_path):
|
|
56
|
+
return False
|
|
57
|
+
try:
|
|
58
|
+
path_stat = os.stat(resolved_candidate)
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
return stat.S_ISREG(path_stat.st_mode)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_trust_root() -> Path:
|
|
65
|
+
claude_home_override = os.environ.get(CLAUDE_HOME_ENV_VAR, "").strip()
|
|
66
|
+
if claude_home_override:
|
|
67
|
+
return Path(claude_home_override).resolve()
|
|
68
|
+
return (Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY).resolve()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_resolved_candidate_allowed(
|
|
72
|
+
resolved_candidate: Path,
|
|
73
|
+
exact_allowed_path: Path | None,
|
|
74
|
+
) -> bool:
|
|
75
|
+
if exact_allowed_path is not None:
|
|
76
|
+
return resolved_candidate == exact_allowed_path
|
|
77
|
+
trusted_prefix = _resolve_trust_root()
|
|
78
|
+
return _is_within_directory(resolved_candidate, trusted_prefix)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_within_directory(candidate_path: Path, directory: Path) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
candidate_path.relative_to(directory)
|
|
84
|
+
return True
|
|
85
|
+
except ValueError:
|
|
86
|
+
return False
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Git pre-commit hook: run the CODE_RULES gate over staged changes.
|
|
3
|
+
|
|
4
|
+
Installed to the user's shared git-hooks directory via the claude-dev-env
|
|
5
|
+
installer; git invokes this file as `pre-commit` (the installer strips the
|
|
6
|
+
`_` and `.py` suffix when copying into the live hooks path).
|
|
7
|
+
|
|
8
|
+
Exit codes:
|
|
9
|
+
0 - staged changes pass the gate (or the gate is not installed locally).
|
|
10
|
+
1 - staged changes introduce one or more blocking violations.
|
|
11
|
+
2 - unexpected invocation failure (e.g., subprocess could not launch).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from config import (
|
|
21
|
+
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
|
|
22
|
+
GATE_SCRIPT_NOT_FOUND_MESSAGE,
|
|
23
|
+
INVOKE_GATE_FAILURE_MESSAGE,
|
|
24
|
+
STAGED_SCOPE_ARGUMENT,
|
|
25
|
+
)
|
|
26
|
+
from gate_utils import is_safe_regular_file, resolve_gate_script_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def invoke_gate(gate_script_path: Path) -> int:
|
|
30
|
+
staged_scope_argument = STAGED_SCOPE_ARGUMENT
|
|
31
|
+
invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
|
|
32
|
+
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
33
|
+
try:
|
|
34
|
+
resolved_gate_path = gate_script_path.resolve(strict=True)
|
|
35
|
+
completion = subprocess.run(
|
|
36
|
+
[sys.executable, str(resolved_gate_path), staged_scope_argument],
|
|
37
|
+
check=False,
|
|
38
|
+
)
|
|
39
|
+
except OSError as launch_error:
|
|
40
|
+
print(
|
|
41
|
+
invoke_gate_failure_message.format(error=launch_error),
|
|
42
|
+
file=sys.stderr,
|
|
43
|
+
)
|
|
44
|
+
return gate_infrastructure_failure_exit_code
|
|
45
|
+
return completion.returncode
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> int:
|
|
49
|
+
gate_script_not_found_message = GATE_SCRIPT_NOT_FOUND_MESSAGE
|
|
50
|
+
gate_script_path, exact_allowed_path = resolve_gate_script_path()
|
|
51
|
+
if not is_safe_regular_file(gate_script_path, exact_allowed_path):
|
|
52
|
+
print(
|
|
53
|
+
gate_script_not_found_message.format(path=gate_script_path),
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
return 0
|
|
57
|
+
return invoke_gate(gate_script_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
sys.exit(main())
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Git pre-push hook: run the CODE_RULES gate over commits about to be pushed.
|
|
3
|
+
|
|
4
|
+
Installed to the user's shared git-hooks directory via the claude-dev-env
|
|
5
|
+
installer; git invokes this file as `pre-push` (the installer strips the
|
|
6
|
+
`_` and `.py` suffix when copying into the live hooks path).
|
|
7
|
+
|
|
8
|
+
Protocol: git pre-push provides remote name and URL as argv, then writes
|
|
9
|
+
`<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin. The
|
|
10
|
+
first non-zero remote-sha is used as the gate `--base`, so violations are
|
|
11
|
+
scoped to commits that are not already on the remote. When every remote
|
|
12
|
+
object name is zero (new branch) or stdin is empty, the gate falls back
|
|
13
|
+
to the remote's default branch symbolic ref.
|
|
14
|
+
|
|
15
|
+
Exit codes:
|
|
16
|
+
0 - commits to be pushed pass the gate (or the gate is not installed).
|
|
17
|
+
1 - one or more commits introduce blocking violations.
|
|
18
|
+
2 - unexpected invocation failure (e.g., subprocess could not launch).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from config import (
|
|
28
|
+
ALL_ZEROS_OBJECT_NAME_CHARACTER,
|
|
29
|
+
BASE_REFERENCE_ARGUMENT,
|
|
30
|
+
DEFAULT_REMOTE_BASE_REFERENCE,
|
|
31
|
+
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
|
|
32
|
+
INVOKE_GATE_FAILURE_MESSAGE,
|
|
33
|
+
LOCAL_SHA_FIELD_INDEX,
|
|
34
|
+
MALFORMED_STDIN_LINE_MESSAGE,
|
|
35
|
+
NO_PARSEABLE_STDIN_LINES_MESSAGE,
|
|
36
|
+
NO_PARSEABLE_STDIN_LINES_SENTINEL,
|
|
37
|
+
PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE,
|
|
38
|
+
STDIN_LINE_FIELD_COUNT,
|
|
39
|
+
STDIN_READ_FAILURE_MESSAGE,
|
|
40
|
+
STDIN_REMOTE_OBJECT_FIELD_INDEX,
|
|
41
|
+
)
|
|
42
|
+
from gate_utils import is_safe_regular_file, resolve_gate_script_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_all_zeros_object_name(object_name: str) -> bool:
|
|
46
|
+
all_zeros_object_name_character = ALL_ZEROS_OBJECT_NAME_CHARACTER
|
|
47
|
+
stripped_object_name = object_name.strip()
|
|
48
|
+
if not stripped_object_name:
|
|
49
|
+
return True
|
|
50
|
+
return all(
|
|
51
|
+
each_character == all_zeros_object_name_character
|
|
52
|
+
for each_character in stripped_object_name
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_base_reference_from_stdin(stdin_text: str) -> str | None:
|
|
57
|
+
stdin_line_field_count = STDIN_LINE_FIELD_COUNT
|
|
58
|
+
stdin_remote_object_field_index = STDIN_REMOTE_OBJECT_FIELD_INDEX
|
|
59
|
+
local_sha_field_index = LOCAL_SHA_FIELD_INDEX
|
|
60
|
+
default_remote_base_reference = DEFAULT_REMOTE_BASE_REFERENCE
|
|
61
|
+
malformed_stdin_line_message = MALFORMED_STDIN_LINE_MESSAGE
|
|
62
|
+
has_seen_any_valid_line = False
|
|
63
|
+
is_all_valid_lines_deletions = True
|
|
64
|
+
has_stdin_content = False
|
|
65
|
+
for each_line in stdin_text.splitlines():
|
|
66
|
+
stripped_line = each_line.strip()
|
|
67
|
+
if not stripped_line:
|
|
68
|
+
continue
|
|
69
|
+
has_stdin_content = True
|
|
70
|
+
fields = stripped_line.split()
|
|
71
|
+
if len(fields) < stdin_line_field_count:
|
|
72
|
+
print(
|
|
73
|
+
malformed_stdin_line_message.format(line=stripped_line),
|
|
74
|
+
file=sys.stderr,
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
has_seen_any_valid_line = True
|
|
78
|
+
if is_all_zeros_object_name(fields[local_sha_field_index]):
|
|
79
|
+
continue
|
|
80
|
+
is_all_valid_lines_deletions = False
|
|
81
|
+
remote_object_name = fields[stdin_remote_object_field_index]
|
|
82
|
+
if not is_all_zeros_object_name(remote_object_name):
|
|
83
|
+
return remote_object_name
|
|
84
|
+
if has_stdin_content and not has_seen_any_valid_line:
|
|
85
|
+
return NO_PARSEABLE_STDIN_LINES_SENTINEL
|
|
86
|
+
if has_seen_any_valid_line and is_all_valid_lines_deletions:
|
|
87
|
+
return None
|
|
88
|
+
return default_remote_base_reference
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
|
|
92
|
+
base_reference_argument = BASE_REFERENCE_ARGUMENT
|
|
93
|
+
invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
|
|
94
|
+
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
95
|
+
try:
|
|
96
|
+
resolved_gate_path = gate_script_path.resolve(strict=True)
|
|
97
|
+
completion = subprocess.run(
|
|
98
|
+
[
|
|
99
|
+
sys.executable,
|
|
100
|
+
str(resolved_gate_path),
|
|
101
|
+
base_reference_argument,
|
|
102
|
+
base_reference,
|
|
103
|
+
],
|
|
104
|
+
check=False,
|
|
105
|
+
)
|
|
106
|
+
except OSError as launch_error:
|
|
107
|
+
print(
|
|
108
|
+
invoke_gate_failure_message.format(error=launch_error),
|
|
109
|
+
file=sys.stderr,
|
|
110
|
+
)
|
|
111
|
+
return gate_infrastructure_failure_exit_code
|
|
112
|
+
return completion.returncode
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> int:
|
|
116
|
+
stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
|
|
117
|
+
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
118
|
+
pre_push_gate_script_not_found_message = PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
|
|
119
|
+
no_parseable_stdin_lines_message = NO_PARSEABLE_STDIN_LINES_MESSAGE
|
|
120
|
+
no_parseable_stdin_lines_sentinel = NO_PARSEABLE_STDIN_LINES_SENTINEL
|
|
121
|
+
gate_script_path, exact_allowed_path = resolve_gate_script_path()
|
|
122
|
+
if not is_safe_regular_file(gate_script_path, exact_allowed_path):
|
|
123
|
+
print(
|
|
124
|
+
pre_push_gate_script_not_found_message.format(path=gate_script_path),
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
return 0
|
|
128
|
+
try:
|
|
129
|
+
stdin_text = sys.stdin.read()
|
|
130
|
+
except OSError as read_error:
|
|
131
|
+
print(
|
|
132
|
+
stdin_read_failure_message.format(error=read_error),
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
return gate_infrastructure_failure_exit_code
|
|
136
|
+
base_reference = resolve_base_reference_from_stdin(stdin_text)
|
|
137
|
+
if base_reference is None:
|
|
138
|
+
return 0
|
|
139
|
+
if base_reference == no_parseable_stdin_lines_sentinel:
|
|
140
|
+
print(no_parseable_stdin_lines_message, file=sys.stderr)
|
|
141
|
+
return gate_infrastructure_failure_exit_code
|
|
142
|
+
return invoke_gate(gate_script_path, base_reference)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
|
|
8
|
+
if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
|
10
|
+
|
|
11
|
+
import config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_pre_push_gate_script_not_found_message_contains_path_placeholder() -> None:
|
|
15
|
+
assert "{path}" in config.PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_no_parseable_stdin_lines_message_exists_and_describes_problem() -> None:
|
|
19
|
+
assert "no parseable stdin lines" in config.NO_PARSEABLE_STDIN_LINES_MESSAGE
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_no_parseable_stdin_lines_sentinel_is_distinct_sentinel_value() -> None:
|
|
23
|
+
assert config.NO_PARSEABLE_STDIN_LINES_SENTINEL is not None
|
|
24
|
+
assert config.NO_PARSEABLE_STDIN_LINES_SENTINEL != config.DEFAULT_REMOTE_BASE_REFERENCE
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
+
if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
import gate_utils
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_resolve_gate_script_path_uses_override_env_var_when_set(
|
|
17
|
+
tmp_path: Path,
|
|
18
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
19
|
+
) -> None:
|
|
20
|
+
override_path = tmp_path / "override_gate.py"
|
|
21
|
+
override_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
22
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(override_path))
|
|
23
|
+
|
|
24
|
+
resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
|
|
25
|
+
|
|
26
|
+
assert resolved_path == override_path
|
|
27
|
+
assert exact_allowed == override_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_resolve_gate_script_path_defaults_to_claude_home_when_env_var_set(
|
|
31
|
+
tmp_path: Path,
|
|
32
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
33
|
+
) -> None:
|
|
34
|
+
monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
|
|
35
|
+
monkeypatch.setenv("CLAUDE_HOME", str(tmp_path))
|
|
36
|
+
|
|
37
|
+
resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
|
|
38
|
+
|
|
39
|
+
expected_path = (
|
|
40
|
+
tmp_path / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
|
|
41
|
+
)
|
|
42
|
+
assert resolved_path == expected_path
|
|
43
|
+
assert exact_allowed is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_resolve_gate_script_path_falls_back_to_home_dot_claude_when_no_env_vars(
|
|
47
|
+
tmp_path: Path,
|
|
48
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
49
|
+
) -> None:
|
|
50
|
+
monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
|
|
51
|
+
monkeypatch.delenv("CLAUDE_HOME", raising=False)
|
|
52
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
|
|
53
|
+
|
|
54
|
+
resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
|
|
55
|
+
|
|
56
|
+
expected_path = (
|
|
57
|
+
tmp_path
|
|
58
|
+
/ ".claude"
|
|
59
|
+
/ "skills"
|
|
60
|
+
/ "bugteam"
|
|
61
|
+
/ "scripts"
|
|
62
|
+
/ "bugteam_code_rules_gate.py"
|
|
63
|
+
)
|
|
64
|
+
assert resolved_path == expected_path
|
|
65
|
+
assert exact_allowed is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_resolve_gate_script_path_resolves_relative_override_to_absolute(
|
|
69
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
70
|
+
) -> None:
|
|
71
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", "relative/gate.py")
|
|
72
|
+
|
|
73
|
+
resolved_path, _ = gate_utils.resolve_gate_script_path()
|
|
74
|
+
|
|
75
|
+
assert resolved_path.is_absolute()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_is_safe_regular_file_rejects_sibling_of_override_path(
|
|
79
|
+
tmp_path: Path,
|
|
80
|
+
) -> None:
|
|
81
|
+
override_gate = tmp_path / "gate.py"
|
|
82
|
+
override_gate.write_text("", encoding="utf-8")
|
|
83
|
+
sibling_script = tmp_path / "attacker_script.py"
|
|
84
|
+
sibling_script.write_text("", encoding="utf-8")
|
|
85
|
+
|
|
86
|
+
is_safe = gate_utils.is_safe_regular_file(sibling_script, override_gate.resolve())
|
|
87
|
+
|
|
88
|
+
assert not is_safe
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_is_safe_regular_file_accepts_exact_override_path(
|
|
92
|
+
tmp_path: Path,
|
|
93
|
+
) -> None:
|
|
94
|
+
override_gate = tmp_path / "gate.py"
|
|
95
|
+
override_gate.write_text("", encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
is_safe = gate_utils.is_safe_regular_file(override_gate, override_gate.resolve())
|
|
98
|
+
|
|
99
|
+
assert is_safe
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_is_safe_regular_file_rejects_claude_home_override_outside_home_dot_claude(
|
|
103
|
+
tmp_path: Path,
|
|
104
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
105
|
+
) -> None:
|
|
106
|
+
attacker_home = tmp_path / "attacker_home"
|
|
107
|
+
gate_under_attacker_home = (
|
|
108
|
+
attacker_home / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
|
|
109
|
+
)
|
|
110
|
+
gate_under_attacker_home.parent.mkdir(parents=True)
|
|
111
|
+
gate_under_attacker_home.write_text("", encoding="utf-8")
|
|
112
|
+
real_home = tmp_path / "real_home"
|
|
113
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: real_home))
|
|
114
|
+
|
|
115
|
+
is_safe = gate_utils.is_safe_regular_file(gate_under_attacker_home, None)
|
|
116
|
+
|
|
117
|
+
assert not is_safe
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_is_safe_regular_file_accepts_gate_inside_home_dot_claude(
|
|
121
|
+
tmp_path: Path,
|
|
122
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
123
|
+
) -> None:
|
|
124
|
+
home_dir = tmp_path / "real_home"
|
|
125
|
+
gate_path = (
|
|
126
|
+
home_dir / ".claude" / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
|
|
127
|
+
)
|
|
128
|
+
gate_path.parent.mkdir(parents=True)
|
|
129
|
+
gate_path.write_text("", encoding="utf-8")
|
|
130
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir))
|
|
131
|
+
|
|
132
|
+
is_safe = gate_utils.is_safe_regular_file(gate_path, None)
|
|
133
|
+
|
|
134
|
+
assert is_safe
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_is_safe_regular_file_rejects_nonexistent_path_under_trusted_prefix(
|
|
138
|
+
tmp_path: Path,
|
|
139
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
140
|
+
) -> None:
|
|
141
|
+
home_dir = tmp_path / "real_home"
|
|
142
|
+
(home_dir / ".claude").mkdir(parents=True)
|
|
143
|
+
missing_gate_path = (
|
|
144
|
+
home_dir / ".claude" / "skills" / "bugteam" / "scripts" / "missing_gate.py"
|
|
145
|
+
)
|
|
146
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir))
|
|
147
|
+
|
|
148
|
+
is_safe = gate_utils.is_safe_regular_file(missing_gate_path, None)
|
|
149
|
+
|
|
150
|
+
assert not is_safe
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_is_safe_regular_file_resolves_symlink_before_prefix_check(
|
|
154
|
+
tmp_path: Path,
|
|
155
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
156
|
+
) -> None:
|
|
157
|
+
home_dir = tmp_path / "home"
|
|
158
|
+
claude_home = home_dir / ".claude"
|
|
159
|
+
claude_home.mkdir(parents=True)
|
|
160
|
+
real_target = tmp_path / "outside_claude" / "evil.py"
|
|
161
|
+
real_target.parent.mkdir(parents=True)
|
|
162
|
+
real_target.write_text("", encoding="utf-8")
|
|
163
|
+
symlink_inside_claude = claude_home / "evil_link.py"
|
|
164
|
+
symlink_inside_claude.symlink_to(real_target)
|
|
165
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir))
|
|
166
|
+
|
|
167
|
+
is_safe = gate_utils.is_safe_regular_file(symlink_inside_claude, None)
|
|
168
|
+
|
|
169
|
+
assert not is_safe
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_is_safe_regular_file_uses_claude_home_env_as_trust_root(
|
|
173
|
+
tmp_path: Path,
|
|
174
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
175
|
+
) -> None:
|
|
176
|
+
custom_claude_home = tmp_path / "custom_claude"
|
|
177
|
+
gate_path = (
|
|
178
|
+
custom_claude_home / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
|
|
179
|
+
)
|
|
180
|
+
gate_path.parent.mkdir(parents=True)
|
|
181
|
+
gate_path.write_text("", encoding="utf-8")
|
|
182
|
+
monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
|
|
183
|
+
monkeypatch.setenv("CLAUDE_HOME", str(custom_claude_home))
|
|
184
|
+
|
|
185
|
+
gate_script_path, exact_allowed = gate_utils.resolve_gate_script_path()
|
|
186
|
+
|
|
187
|
+
is_safe = gate_utils.is_safe_regular_file(gate_script_path, exact_allowed)
|
|
188
|
+
|
|
189
|
+
assert is_safe
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_resolve_gate_script_path_snapshot_is_consistent_with_is_safe_regular_file(
|
|
193
|
+
tmp_path: Path,
|
|
194
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
195
|
+
) -> None:
|
|
196
|
+
custom_claude_home = tmp_path / "custom_claude"
|
|
197
|
+
gate_path = (
|
|
198
|
+
custom_claude_home / "skills" / "bugteam" / "scripts" / "bugteam_code_rules_gate.py"
|
|
199
|
+
)
|
|
200
|
+
gate_path.parent.mkdir(parents=True)
|
|
201
|
+
gate_path.write_text("", encoding="utf-8")
|
|
202
|
+
monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
|
|
203
|
+
monkeypatch.setenv("CLAUDE_HOME", str(custom_claude_home))
|
|
204
|
+
|
|
205
|
+
resolved_path, exact_allowed = gate_utils.resolve_gate_script_path()
|
|
206
|
+
is_safe = gate_utils.is_safe_regular_file(resolved_path, exact_allowed)
|
|
207
|
+
|
|
208
|
+
assert is_safe
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_is_safe_regular_file_rejects_path_outside_claude_home_env_trust_root(
|
|
212
|
+
tmp_path: Path,
|
|
213
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
214
|
+
) -> None:
|
|
215
|
+
custom_claude_home = tmp_path / "custom_claude"
|
|
216
|
+
custom_claude_home.mkdir(parents=True)
|
|
217
|
+
outside_path = tmp_path / "outside" / "gate.py"
|
|
218
|
+
outside_path.parent.mkdir(parents=True)
|
|
219
|
+
outside_path.write_text("", encoding="utf-8")
|
|
220
|
+
monkeypatch.delenv("CODE_RULES_GATE_PATH", raising=False)
|
|
221
|
+
monkeypatch.setenv("CLAUDE_HOME", str(custom_claude_home))
|
|
222
|
+
|
|
223
|
+
is_safe = gate_utils.is_safe_regular_file(outside_path, None)
|
|
224
|
+
|
|
225
|
+
assert not is_safe
|