claude-dev-env 8.97.0 → 8.98.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/install.prompt-context.test.mjs +3 -2
- package/bin/install.settings-defaults.test.mjs +4 -3
- package/docs/agents-guidance/asks.md +6 -1
- package/hooks/blocking/open_tasks_stop_gate.py +128 -34
- package/hooks/blocking/open_tasks_transcript.py +112 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
- package/hooks/blocking/python_windows_invocation_gate.py +249 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
- package/hooks/blocking/test_open_tasks_transcript.py +89 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
- package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
- package/hooks/blocking/verify_before_commit_gate.py +278 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +10 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
- package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
- package/hooks/open_task_asks.py +163 -0
- package/hooks/session/open_task_ask_recorder.py +38 -0
- package/hooks/session/test_open_task_ask_recorder.py +57 -0
- package/hooks/test_open_task_asks.py +52 -0
- package/hooks/test_transcript_skill_scan.py +24 -0
- package/hooks/transcript_skill_scan.py +54 -3
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/settings.json +2 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Tests for python_windows_invocation_gate, which stops two Windows Python failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import python_windows_invocation_gate as gate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
15
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
16
|
+
captured_stdout = StringIO()
|
|
17
|
+
with (
|
|
18
|
+
patch("sys.stdin", StringIO(json.dumps(payload))),
|
|
19
|
+
patch("sys.stdout", captured_stdout),
|
|
20
|
+
patch.object(gate, "log_hook_block"),
|
|
21
|
+
):
|
|
22
|
+
gate.main()
|
|
23
|
+
return captured_stdout.getvalue()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def windows_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
28
|
+
"""Make the gate read the host as Windows."""
|
|
29
|
+
monkeypatch.setattr(gate, "is_windows_host", lambda: True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def linux_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
34
|
+
"""Make the gate read the host as Linux."""
|
|
35
|
+
monkeypatch.setattr(gate, "is_windows_host", lambda: False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize(
|
|
39
|
+
"command",
|
|
40
|
+
[
|
|
41
|
+
"python -I script.py",
|
|
42
|
+
"python3 -I -c pass",
|
|
43
|
+
"py -3 -I script.py",
|
|
44
|
+
"python3.12.exe -I -m json.tool data.json",
|
|
45
|
+
"python -Iu script.py",
|
|
46
|
+
"python -Ic pass",
|
|
47
|
+
"python -I -X dev script.py",
|
|
48
|
+
"python -I -X utf8=0 script.py",
|
|
49
|
+
"cd repo && python -I script.py",
|
|
50
|
+
"git status; python -I script.py | tee out.txt",
|
|
51
|
+
"bash -c 'python -I script.py'",
|
|
52
|
+
"python --check-hash-based-pycs always -I script.py",
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
def test_should_flag_isolated_python_without_utf8_mode(command: str) -> None:
|
|
56
|
+
assert gate.runs_isolated_python_without_utf8_mode(command) is True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.parametrize(
|
|
60
|
+
"command",
|
|
61
|
+
[
|
|
62
|
+
"python -I -X utf8 script.py",
|
|
63
|
+
"python -IX utf8 script.py",
|
|
64
|
+
"python -IXutf8 script.py",
|
|
65
|
+
"python -X utf8 -I script.py",
|
|
66
|
+
"python -I -X utf8=1 -c pass",
|
|
67
|
+
"py -3 -I -X utf8 script.py",
|
|
68
|
+
"python -I --check-hash-based-pycs always -X utf8 script.py",
|
|
69
|
+
"python script.py -I",
|
|
70
|
+
"python -c pass -I",
|
|
71
|
+
"python -m pytest -I",
|
|
72
|
+
"python script.py",
|
|
73
|
+
"pip install -I package",
|
|
74
|
+
'echo "python -I script.py"',
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
def test_should_pass_python_with_utf8_mode_or_without_isolated_mode(
|
|
78
|
+
command: str,
|
|
79
|
+
) -> None:
|
|
80
|
+
assert gate.runs_isolated_python_without_utf8_mode(command) is False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize(
|
|
84
|
+
"command",
|
|
85
|
+
[
|
|
86
|
+
'python -X utf8 -c "import json; print(\\"select 1\\")"',
|
|
87
|
+
'python -c "print(\\"x\\")"',
|
|
88
|
+
'py -3 -c "print(\\"x\\")"',
|
|
89
|
+
'python3.exe -I -X utf8 -c "print(\\"x\\")"',
|
|
90
|
+
'& python -c "print(\\"x\\")"',
|
|
91
|
+
'Set-Location repo; python -c "print(\\"x\\")"',
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
def test_should_flag_a_backslash_quote_inside_a_python_code_argument(
|
|
95
|
+
command: str,
|
|
96
|
+
) -> None:
|
|
97
|
+
assert gate.has_backslash_quote_in_python_code_argument(command) is True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
"command",
|
|
102
|
+
[
|
|
103
|
+
"python -c \"print('x')\"",
|
|
104
|
+
"python -c 'print(\"x\")'",
|
|
105
|
+
'python script.py "a\\"b"',
|
|
106
|
+
'python -c "print(1)"; Write-Output "a\\"b"',
|
|
107
|
+
'Write-Output "python -c"',
|
|
108
|
+
'copy -c "a\\"b"',
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_should_pass_a_python_code_argument_without_a_backslash_quote(
|
|
112
|
+
command: str,
|
|
113
|
+
) -> None:
|
|
114
|
+
assert gate.has_backslash_quote_in_python_code_argument(command) is False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_deny_isolated_python_on_windows_and_name_the_utf8_flag(
|
|
118
|
+
windows_host: None,
|
|
119
|
+
) -> None:
|
|
120
|
+
decision = json.loads(
|
|
121
|
+
_stdout_from_main(
|
|
122
|
+
{"tool_name": "Bash", "tool_input": {"command": "python -I s.py"}}
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
specific_output = decision["hookSpecificOutput"]
|
|
126
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
127
|
+
assert "python -I -X utf8" in specific_output["permissionDecisionReason"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_should_stay_quiet_for_isolated_python_on_linux(linux_host: None) -> None:
|
|
131
|
+
assert (
|
|
132
|
+
_stdout_from_main(
|
|
133
|
+
{"tool_name": "Bash", "tool_input": {"command": "python -I s.py"}}
|
|
134
|
+
)
|
|
135
|
+
== ""
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_should_deny_a_powershell_backslash_quote_and_name_the_here_string(
|
|
140
|
+
linux_host: None,
|
|
141
|
+
) -> None:
|
|
142
|
+
command = 'python -X utf8 -c "print(\\"select 1\\")"'
|
|
143
|
+
decision = json.loads(
|
|
144
|
+
_stdout_from_main(
|
|
145
|
+
{"tool_name": "PowerShell", "tool_input": {"command": command}}
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
specific_output = decision["hookSpecificOutput"]
|
|
149
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
150
|
+
assert "here-string" in specific_output["permissionDecisionReason"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_should_pass_a_backslash_quote_under_bash(windows_host: None) -> None:
|
|
154
|
+
command = 'python -X utf8 -c "print(\\"select 1\\")"'
|
|
155
|
+
assert (
|
|
156
|
+
_stdout_from_main({"tool_name": "Bash", "tool_input": {"command": command}})
|
|
157
|
+
== ""
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_should_name_both_fixes_when_a_powershell_command_has_both_failures(
|
|
162
|
+
windows_host: None,
|
|
163
|
+
) -> None:
|
|
164
|
+
command = 'python -I -c "print(\\"x\\")"'
|
|
165
|
+
decision = json.loads(
|
|
166
|
+
_stdout_from_main(
|
|
167
|
+
{"tool_name": "PowerShell", "tool_input": {"command": command}}
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
reason = decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
171
|
+
assert "python -I -X utf8" in reason
|
|
172
|
+
assert "here-string" in reason
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_should_stay_quiet_for_another_tool(windows_host: None) -> None:
|
|
176
|
+
assert (
|
|
177
|
+
_stdout_from_main(
|
|
178
|
+
{"tool_name": "Write", "tool_input": {"command": "python -I s.py"}}
|
|
179
|
+
)
|
|
180
|
+
== ""
|
|
181
|
+
)
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Behavior tests for the verify-before-commit PreToolUse gate."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from io import BytesIO, StringIO, TextIOWrapper
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
13
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
15
|
+
|
|
16
|
+
from blocking import verify_before_commit_gate as gate
|
|
17
|
+
from hooks_constants.verify_before_commit_gate_constants import DENY_REASON
|
|
18
|
+
|
|
19
|
+
PROMPT_ENTRY = {"type": "user", "message": {"content": "Fix the parser and commit it."}}
|
|
20
|
+
SLASH_PROMPT_ENTRY = {
|
|
21
|
+
"type": "user",
|
|
22
|
+
"message": {
|
|
23
|
+
"content": "<command-message>verify</command-message>\n<command-name>/verify</command-name>"
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
SKILL_BODY_ENTRY = {
|
|
27
|
+
"type": "user",
|
|
28
|
+
"isMeta": True,
|
|
29
|
+
"message": {
|
|
30
|
+
"content": [{"type": "text", "text": "Base directory for this skill: verify"}]
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
TOOL_RESULT_ENTRY = {
|
|
34
|
+
"type": "user",
|
|
35
|
+
"message": {
|
|
36
|
+
"content": [
|
|
37
|
+
{
|
|
38
|
+
"type": "tool_result",
|
|
39
|
+
"tool_use_id": "toolu_1",
|
|
40
|
+
"content": "Launching skill: verify",
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _skill_entry(skill_name: str) -> dict[str, object]:
|
|
48
|
+
return {
|
|
49
|
+
"type": "assistant",
|
|
50
|
+
"message": {
|
|
51
|
+
"content": [
|
|
52
|
+
{"type": "tool_use", "name": "Skill", "input": {"skill": skill_name}}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _git(repository: Path, *all_arguments: str) -> None:
|
|
59
|
+
subprocess.run(
|
|
60
|
+
["git", "-C", str(repository), *all_arguments], check=True, capture_output=True
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture
|
|
65
|
+
def repository(tmp_path: Path) -> Path:
|
|
66
|
+
repository_root = tmp_path / "repo"
|
|
67
|
+
repository_root.mkdir()
|
|
68
|
+
_git(repository_root, "init", "-q")
|
|
69
|
+
_git(repository_root, "config", "user.email", "gate@example.com")
|
|
70
|
+
_git(repository_root, "config", "user.name", "Gate Test")
|
|
71
|
+
(repository_root / ".agents" / "skills" / "verify").mkdir(parents=True)
|
|
72
|
+
(repository_root / ".agents" / "skills" / "verify" / "SKILL.md").write_text(
|
|
73
|
+
"verify\n", encoding="utf-8"
|
|
74
|
+
)
|
|
75
|
+
(repository_root / "app.py").write_text("VALUE = 1\n", encoding="utf-8")
|
|
76
|
+
(repository_root / "README.md").write_text("Readme\n", encoding="utf-8")
|
|
77
|
+
_git(repository_root, "add", "-A")
|
|
78
|
+
_git(repository_root, "commit", "-q", "-m", "base")
|
|
79
|
+
return repository_root
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _transcript(tmp_path: Path, *all_entries: dict[str, object]) -> Path:
|
|
83
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
84
|
+
transcript_path.write_text(
|
|
85
|
+
"".join(json.dumps(each_entry) + "\n" for each_entry in all_entries),
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
)
|
|
88
|
+
return transcript_path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _payload(
|
|
92
|
+
command: str, cwd: Path, transcript_path: Path, tool_name: str = "Bash"
|
|
93
|
+
) -> dict[str, object]:
|
|
94
|
+
return {
|
|
95
|
+
"tool_name": tool_name,
|
|
96
|
+
"tool_input": {"command": command},
|
|
97
|
+
"cwd": str(cwd),
|
|
98
|
+
"transcript_path": str(transcript_path),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unverified(tmp_path: Path) -> Path:
|
|
103
|
+
return _transcript(tmp_path, PROMPT_ENTRY)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_denied(payload: dict[str, object]) -> bool:
|
|
107
|
+
decision = gate.decision_for(payload)
|
|
108
|
+
if decision is None:
|
|
109
|
+
return False
|
|
110
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
111
|
+
assert decision["hookSpecificOutput"]["permissionDecisionReason"] == DENY_REASON
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run_main(payload: dict[str, object]) -> tuple[int, str]:
|
|
116
|
+
output = StringIO()
|
|
117
|
+
input_stream = TextIOWrapper(
|
|
118
|
+
BytesIO(json.dumps(payload).encode("utf-8")), encoding="utf-8"
|
|
119
|
+
)
|
|
120
|
+
with patch("sys.stdin", input_stream), patch("sys.stdout", output):
|
|
121
|
+
exit_code = gate.main()
|
|
122
|
+
return exit_code, output.getvalue()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_staged_code_commit_without_verify_is_denied(
|
|
126
|
+
repository: Path, tmp_path: Path
|
|
127
|
+
) -> None:
|
|
128
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
129
|
+
_git(repository, "add", "app.py")
|
|
130
|
+
exit_code, output = _run_main(
|
|
131
|
+
_payload("git commit -m change", repository, _unverified(tmp_path))
|
|
132
|
+
)
|
|
133
|
+
assert exit_code == 0
|
|
134
|
+
decision = json.loads(output)["hookSpecificOutput"]
|
|
135
|
+
assert decision["permissionDecision"] == "deny"
|
|
136
|
+
assert "verify" in decision["permissionDecisionReason"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_same_command_git_add_of_code_is_denied_with_an_empty_index(
|
|
140
|
+
repository: Path, tmp_path: Path
|
|
141
|
+
) -> None:
|
|
142
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
143
|
+
assert _is_denied(
|
|
144
|
+
_payload(
|
|
145
|
+
"git add app.py && git commit -m change", repository, _unverified(tmp_path)
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_same_command_git_add_all_picks_up_a_new_code_file(
|
|
151
|
+
repository: Path, tmp_path: Path
|
|
152
|
+
) -> None:
|
|
153
|
+
(repository / "new_module.py").write_text("NAME = 'new'\n", encoding="utf-8")
|
|
154
|
+
assert _is_denied(
|
|
155
|
+
_payload("git add -A; git commit -m add", repository, _unverified(tmp_path))
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_same_command_git_add_of_docs_passes_while_code_stays_unstaged(
|
|
160
|
+
repository: Path, tmp_path: Path
|
|
161
|
+
) -> None:
|
|
162
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
163
|
+
(repository / "README.md").write_text("Readme two\n", encoding="utf-8")
|
|
164
|
+
assert not _is_denied(
|
|
165
|
+
_payload(
|
|
166
|
+
"git add README.md; git commit -m docs", repository, _unverified(tmp_path)
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_commit_all_flag_counts_modified_code(repository: Path, tmp_path: Path) -> None:
|
|
172
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
173
|
+
assert _is_denied(
|
|
174
|
+
_payload("git commit -am change", repository, _unverified(tmp_path))
|
|
175
|
+
)
|
|
176
|
+
assert _is_denied(
|
|
177
|
+
_payload("git commit --all -m change", repository, _unverified(tmp_path))
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_commit_all_flag_with_only_docs_modified_passes(
|
|
182
|
+
repository: Path, tmp_path: Path
|
|
183
|
+
) -> None:
|
|
184
|
+
(repository / "README.md").write_text("Readme two\n", encoding="utf-8")
|
|
185
|
+
assert not _is_denied(
|
|
186
|
+
_payload("git commit -am docs", repository, _unverified(tmp_path))
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_docs_only_commit_passes(repository: Path, tmp_path: Path) -> None:
|
|
191
|
+
(repository / "README.md").write_text("Readme two\n", encoding="utf-8")
|
|
192
|
+
(repository / "docs").mkdir()
|
|
193
|
+
(repository / "docs" / "guide.md").write_text("Guide\n", encoding="utf-8")
|
|
194
|
+
_git(repository, "add", "-A")
|
|
195
|
+
assert not _is_denied(
|
|
196
|
+
_payload("git commit -m docs", repository, _unverified(tmp_path))
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_tests_only_commit_passes(repository: Path, tmp_path: Path) -> None:
|
|
201
|
+
(repository / "test_app.py").write_text(
|
|
202
|
+
"def test_value() -> None:\n pass\n", encoding="utf-8"
|
|
203
|
+
)
|
|
204
|
+
(repository / "tests").mkdir()
|
|
205
|
+
(repository / "tests" / "helpers.py").write_text("HELPER = 1\n", encoding="utf-8")
|
|
206
|
+
(repository / "install.test.mjs").write_text("export {};\n", encoding="utf-8")
|
|
207
|
+
_git(repository, "add", "-A")
|
|
208
|
+
assert not _is_denied(
|
|
209
|
+
_payload("git commit -m tests", repository, _unverified(tmp_path))
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_docs_plus_code_commit_is_denied(repository: Path, tmp_path: Path) -> None:
|
|
214
|
+
(repository / "README.md").write_text("Readme two\n", encoding="utf-8")
|
|
215
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
216
|
+
_git(repository, "add", "-A")
|
|
217
|
+
assert _is_denied(
|
|
218
|
+
_payload("git commit -m mixed", repository, _unverified(tmp_path))
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_verify_skill_call_after_the_last_prompt_passes(
|
|
223
|
+
repository: Path, tmp_path: Path
|
|
224
|
+
) -> None:
|
|
225
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
226
|
+
_git(repository, "add", "app.py")
|
|
227
|
+
for each_name in ("verify", "plugin:verify"):
|
|
228
|
+
transcript_path = _transcript(
|
|
229
|
+
tmp_path,
|
|
230
|
+
PROMPT_ENTRY,
|
|
231
|
+
_skill_entry(each_name),
|
|
232
|
+
TOOL_RESULT_ENTRY,
|
|
233
|
+
SKILL_BODY_ENTRY,
|
|
234
|
+
)
|
|
235
|
+
assert not _is_denied(
|
|
236
|
+
_payload("git commit -m change", repository, transcript_path)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_verify_slash_command_in_the_last_prompt_passes(
|
|
241
|
+
repository: Path, tmp_path: Path
|
|
242
|
+
) -> None:
|
|
243
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
244
|
+
_git(repository, "add", "app.py")
|
|
245
|
+
transcript_path = _transcript(
|
|
246
|
+
tmp_path, PROMPT_ENTRY, SLASH_PROMPT_ENTRY, SKILL_BODY_ENTRY
|
|
247
|
+
)
|
|
248
|
+
exit_code, output = _run_main(
|
|
249
|
+
_payload("git commit -m change", repository, transcript_path)
|
|
250
|
+
)
|
|
251
|
+
assert (exit_code, output) == (0, "")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_verify_loaded_in_a_previous_turn_does_not_count(
|
|
255
|
+
repository: Path, tmp_path: Path
|
|
256
|
+
) -> None:
|
|
257
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
258
|
+
_git(repository, "add", "app.py")
|
|
259
|
+
transcript_path = _transcript(
|
|
260
|
+
tmp_path, PROMPT_ENTRY, _skill_entry("verify"), SKILL_BODY_ENTRY, PROMPT_ENTRY
|
|
261
|
+
)
|
|
262
|
+
assert _is_denied(_payload("git commit -m change", repository, transcript_path))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_another_skill_does_not_count(repository: Path, tmp_path: Path) -> None:
|
|
266
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
267
|
+
_git(repository, "add", "app.py")
|
|
268
|
+
transcript_path = _transcript(tmp_path, PROMPT_ENTRY, _skill_entry("verify-rdp"))
|
|
269
|
+
assert _is_denied(_payload("git commit -m change", repository, transcript_path))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_repository_without_a_verify_skill_passes(
|
|
273
|
+
repository: Path, tmp_path: Path
|
|
274
|
+
) -> None:
|
|
275
|
+
_git(repository, "rm", "-q", "-r", ".agents")
|
|
276
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
277
|
+
_git(repository, "add", "-A")
|
|
278
|
+
assert not _is_denied(
|
|
279
|
+
_payload("git commit -m change", repository, _unverified(tmp_path))
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_claude_skills_verify_directory_activates_the_gate(
|
|
284
|
+
repository: Path, tmp_path: Path
|
|
285
|
+
) -> None:
|
|
286
|
+
_git(repository, "rm", "-q", "-r", ".agents")
|
|
287
|
+
(repository / ".claude" / "skills" / "verify").mkdir(parents=True)
|
|
288
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
289
|
+
assert _is_denied(
|
|
290
|
+
_payload(
|
|
291
|
+
"git add app.py; git commit -m change", repository, _unverified(tmp_path)
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_powershell_and_git_directory_option_reach_the_repository(
|
|
297
|
+
repository: Path, tmp_path: Path
|
|
298
|
+
) -> None:
|
|
299
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
300
|
+
command = (
|
|
301
|
+
f'git -C "{repository}" add app.py; git -C "{repository}" commit -m change'
|
|
302
|
+
)
|
|
303
|
+
assert _is_denied(
|
|
304
|
+
_payload(command, tmp_path, _unverified(tmp_path), tool_name="PowerShell")
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_commands_other_than_commit_pass(repository: Path, tmp_path: Path) -> None:
|
|
309
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
310
|
+
_git(repository, "add", "app.py")
|
|
311
|
+
for each_command in ("git status", "git push origin HEAD", "echo git commit"):
|
|
312
|
+
assert not _is_denied(_payload(each_command, repository, _unverified(tmp_path)))
|
|
313
|
+
assert (
|
|
314
|
+
gate.decision_for(
|
|
315
|
+
{"tool_name": "Write", "tool_input": {"command": "git commit"}}
|
|
316
|
+
)
|
|
317
|
+
is None
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_unreadable_transcript_passes(repository: Path, tmp_path: Path) -> None:
|
|
322
|
+
(repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
323
|
+
_git(repository, "add", "app.py")
|
|
324
|
+
assert not _is_denied(
|
|
325
|
+
_payload("git commit -m change", repository, tmp_path / "missing.jsonl")
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_directory_outside_a_repository_passes(tmp_path: Path) -> None:
|
|
330
|
+
outside = tmp_path / "outside"
|
|
331
|
+
outside.mkdir()
|
|
332
|
+
assert not _is_denied(
|
|
333
|
+
_payload("git commit -m change", outside, _unverified(tmp_path))
|
|
334
|
+
)
|