claude-dev-env 8.40.1 → 8.41.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/agents/test_agent_frontmatter.py +17 -14
- package/.agents/agents-archived/clean-coder.md +4 -4
- package/.agents/agents-archived/pr-description-writer.md +1 -1
- package/.agents/skills/pr-lifecycle/SKILL.md +470 -0
- package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/ever-shipped-skills.test.mjs +4 -0
- package/docs/rule-guides/code-standards.md +1 -1
- package/docs/rule-guides/destructive-commands.md +1 -1
- package/hooks/blocking/pr_lifecycle_skill_gate.py +190 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +163 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +33 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -10
- package/hooks/hooks_constants/test_pr_lifecycle_skill_gate_constants.py +12 -0
- package/hooks/hooks_constants/test_transcript_skill_scan_constants.py +7 -0
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +7 -0
- package/hooks/session/skill_loaded_reminder.py +4 -51
- package/hooks/session/test_skill_loaded_reminder.py +4 -0
- package/hooks/test_transcript_skill_scan.py +58 -0
- package/hooks/transcript_skill_scan.py +93 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/shell-invocation.md +2 -14
- package/rules/skill-pointers.md +3 -0
- 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/scripts/tests/test_banned_prose_words.py +1 -1
- package/scripts/tests/test_rule_load_scopes.py +1 -7
- package/rules/agent-merges-its-own-green-pull-request.md +0 -55
- package/rules/ci-owns-the-gate.md +0 -101
- package/rules/durable-post-artifacts.md +0 -76
- package/rules/gh-cli-conventions.md +0 -36
- package/rules/git-workflow.md +0 -121
- package/rules/re-stage-before-commit.md +0 -14
- package/rules/review-closure-is-a-check.md +0 -54
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Deny pull request lifecycle actions until their skill appears in the transcript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if hooks_directory not in sys.path:
|
|
13
|
+
sys.path.insert(0, hooks_directory)
|
|
14
|
+
|
|
15
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
16
|
+
ALL_API_ACTION_NAMES,
|
|
17
|
+
ALL_COMMAND_PREFIX_WORDS,
|
|
18
|
+
ALL_GITHUB_MCP_TOOL_SUFFIXES,
|
|
19
|
+
ALL_PREFIX_OPTIONS_WITH_VALUE,
|
|
20
|
+
ALL_SLASH_COMMAND_MARKERS,
|
|
21
|
+
ALL_TRANSCRIPT_PATH_FIELDS,
|
|
22
|
+
COMMAND_SEPARATORS,
|
|
23
|
+
COMMAND_WHITESPACE,
|
|
24
|
+
DENY_DECISION,
|
|
25
|
+
DENY_REASON,
|
|
26
|
+
ENVIRONMENT_ASSIGNMENT_PATTERN,
|
|
27
|
+
GH_API_COMMAND,
|
|
28
|
+
GH_OPTIONS_WITH_VALUE,
|
|
29
|
+
GH_PULL_REQUEST_COMMAND,
|
|
30
|
+
GIT_ACTIONS,
|
|
31
|
+
GIT_GLOBAL_OPTIONS_WITH_VALUE,
|
|
32
|
+
HOOK_EVENT_NAME,
|
|
33
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
34
|
+
MERGE_PATH_SUFFIX,
|
|
35
|
+
OPTION_AND_VALUE_WORD_COUNT,
|
|
36
|
+
PERMISSION_DECISION_KEY,
|
|
37
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
38
|
+
SHELL_TOOL_NAMES,
|
|
39
|
+
SKILL_NAME,
|
|
40
|
+
)
|
|
41
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
42
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
43
|
+
from transcript_skill_scan import is_skill_loaded_after_last_compaction
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _command_segments(command: str) -> list[list[str]]:
|
|
47
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=COMMAND_SEPARATORS)
|
|
48
|
+
lexer.whitespace = COMMAND_WHITESPACE
|
|
49
|
+
lexer.whitespace_split = True
|
|
50
|
+
all_segments: list[list[str]] = []
|
|
51
|
+
each_segment: list[str] = []
|
|
52
|
+
try:
|
|
53
|
+
all_words = list(lexer)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return []
|
|
56
|
+
for each_word in all_words:
|
|
57
|
+
if each_word[0] in COMMAND_SEPARATORS:
|
|
58
|
+
all_segments.append(each_segment)
|
|
59
|
+
each_segment = []
|
|
60
|
+
continue
|
|
61
|
+
each_segment.append(each_word)
|
|
62
|
+
if each_segment:
|
|
63
|
+
all_segments.append(each_segment)
|
|
64
|
+
return [each_group for each_group in all_segments if each_group]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _next_command_word(
|
|
68
|
+
all_words: list[str], start: int, all_options_with_value: frozenset[str]
|
|
69
|
+
) -> tuple[str | None, int]:
|
|
70
|
+
index = start
|
|
71
|
+
while index < len(all_words):
|
|
72
|
+
each_word = all_words[index]
|
|
73
|
+
if each_word in all_options_with_value:
|
|
74
|
+
index += OPTION_AND_VALUE_WORD_COUNT
|
|
75
|
+
continue
|
|
76
|
+
if each_word.startswith("-"):
|
|
77
|
+
index += 1
|
|
78
|
+
continue
|
|
79
|
+
return each_word, index + 1
|
|
80
|
+
return None, index
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_git_action(all_words: list[str]) -> bool:
|
|
84
|
+
action, _ = _next_command_word(all_words, 1, GIT_GLOBAL_OPTIONS_WITH_VALUE)
|
|
85
|
+
return action in GIT_ACTIONS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_gh_action(all_words: list[str]) -> bool:
|
|
89
|
+
action, next_index = _next_command_word(all_words, 1, GH_OPTIONS_WITH_VALUE)
|
|
90
|
+
if action == GH_PULL_REQUEST_COMMAND:
|
|
91
|
+
subcommand, _ = _next_command_word(all_words, next_index, GH_OPTIONS_WITH_VALUE)
|
|
92
|
+
return subcommand is not None
|
|
93
|
+
if action != GH_API_COMMAND:
|
|
94
|
+
return False
|
|
95
|
+
all_api_arguments = all_words[next_index:]
|
|
96
|
+
if any(each_name in each_word for each_word in all_api_arguments for each_name in ALL_API_ACTION_NAMES):
|
|
97
|
+
return True
|
|
98
|
+
return any(
|
|
99
|
+
each_word.split("?", 1)[0].rstrip("/").endswith(MERGE_PATH_SUFFIX)
|
|
100
|
+
for each_word in all_api_arguments
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _words_from_executable(all_words: list[str]) -> list[str]:
|
|
105
|
+
index = 0
|
|
106
|
+
while index < len(all_words):
|
|
107
|
+
each_word = all_words[index]
|
|
108
|
+
if re.match(ENVIRONMENT_ASSIGNMENT_PATTERN, each_word):
|
|
109
|
+
index += 1
|
|
110
|
+
continue
|
|
111
|
+
if each_word not in ALL_COMMAND_PREFIX_WORDS:
|
|
112
|
+
return all_words[index:]
|
|
113
|
+
index += 1
|
|
114
|
+
while index < len(all_words) and all_words[index].startswith("-"):
|
|
115
|
+
is_valued = all_words[index] in ALL_PREFIX_OPTIONS_WITH_VALUE
|
|
116
|
+
index += OPTION_AND_VALUE_WORD_COUNT if is_valued else 1
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _matches_shell_command(command: str) -> bool:
|
|
121
|
+
for each_segment in _command_segments(command):
|
|
122
|
+
all_command_words = _words_from_executable(each_segment)
|
|
123
|
+
if not all_command_words:
|
|
124
|
+
continue
|
|
125
|
+
executable = all_command_words[0].lower()
|
|
126
|
+
if executable in {"git", "git.exe"} and _is_git_action(all_command_words):
|
|
127
|
+
return True
|
|
128
|
+
if executable in {"gh", "gh.exe"} and _is_gh_action(all_command_words):
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_governed_action(all_payload_fields: dict[str, object]) -> bool:
|
|
134
|
+
tool_name = all_payload_fields.get("tool_name")
|
|
135
|
+
if not isinstance(tool_name, str):
|
|
136
|
+
return False
|
|
137
|
+
if tool_name.endswith(ALL_GITHUB_MCP_TOOL_SUFFIXES):
|
|
138
|
+
return True
|
|
139
|
+
if tool_name not in SHELL_TOOL_NAMES:
|
|
140
|
+
return False
|
|
141
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
142
|
+
return isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str) and _matches_shell_command(tool_input["command"])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _transcript_load_status(path: str) -> bool | None:
|
|
146
|
+
try:
|
|
147
|
+
with open(path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
148
|
+
return is_skill_loaded_after_last_compaction(
|
|
149
|
+
transcript, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
|
|
150
|
+
)
|
|
151
|
+
except OSError:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
156
|
+
"""Return a deny for a governed action with readable, unloaded transcripts.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
160
|
+
"""
|
|
161
|
+
if not _is_governed_action(all_payload_fields):
|
|
162
|
+
return None
|
|
163
|
+
paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
|
|
164
|
+
readable_paths = [path for path in paths if isinstance(path, str) and path]
|
|
165
|
+
if not readable_paths:
|
|
166
|
+
return None
|
|
167
|
+
statuses = [_transcript_load_status(path) for path in readable_paths]
|
|
168
|
+
if any(status is None or status for status in statuses):
|
|
169
|
+
return None
|
|
170
|
+
return {
|
|
171
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
172
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
173
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
174
|
+
PERMISSION_DECISION_REASON_KEY: DENY_REASON,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> int:
|
|
180
|
+
"""Read one payload, print a deny when needed, and exit zero."""
|
|
181
|
+
payload = read_hook_input_dictionary_from_stdin()
|
|
182
|
+
if payload is not None:
|
|
183
|
+
decision = decision_for(payload)
|
|
184
|
+
if decision is not None:
|
|
185
|
+
sys.stdout.write(json.dumps(decision) + "\n")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
sys.exit(main())
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Behavior tests for the pull request lifecycle PreToolUse gate."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from io import BytesIO, StringIO, TextIOWrapper
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
10
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
from blocking import pr_lifecycle_skill_gate as gate
|
|
14
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import DENY_REASON
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _transcript(tmp_path: Path, skill_name: str | None = None, compact: bool = False) -> Path:
|
|
18
|
+
lines = [json.dumps({"type": "user", "message": {"content": "Start."}})]
|
|
19
|
+
if skill_name:
|
|
20
|
+
lines.append(json.dumps({"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": skill_name}}]}}))
|
|
21
|
+
if compact:
|
|
22
|
+
lines.append(json.dumps({"subtype": "compact_boundary"}))
|
|
23
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
24
|
+
transcript_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
25
|
+
return transcript_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _payload(command: str, path: Path, tool_name: str = "Bash") -> dict[str, object]:
|
|
29
|
+
return {"tool_name": tool_name, "tool_input": {"command": command}, "transcript_path": str(path)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_main(payload: object) -> tuple[int, str]:
|
|
33
|
+
stdin_text = payload if isinstance(payload, str) else json.dumps(payload)
|
|
34
|
+
output = StringIO()
|
|
35
|
+
input_stream = TextIOWrapper(BytesIO(stdin_text.encode("utf-8")), encoding="utf-8")
|
|
36
|
+
with patch("sys.stdin", input_stream), patch("sys.stdout", output):
|
|
37
|
+
exit_code = gate.main()
|
|
38
|
+
return exit_code, output.getvalue()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _assert_denied(payload: dict[str, object]) -> None:
|
|
42
|
+
assert gate.decision_for(payload) == {
|
|
43
|
+
"hookSpecificOutput": {
|
|
44
|
+
"hookEventName": "PreToolUse",
|
|
45
|
+
"permissionDecision": "deny",
|
|
46
|
+
"permissionDecisionReason": DENY_REASON,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_unloaded_pull_request_command_is_denied(tmp_path: Path) -> None:
|
|
52
|
+
exit_code, output = _run_main(_payload("gh pr create", _transcript(tmp_path)))
|
|
53
|
+
assert exit_code == 0
|
|
54
|
+
decision = json.loads(output)["hookSpecificOutput"]
|
|
55
|
+
assert decision["hookEventName"] == "PreToolUse"
|
|
56
|
+
assert decision["permissionDecision"] == "deny"
|
|
57
|
+
assert "Skill tool" in decision["permissionDecisionReason"]
|
|
58
|
+
assert "same command again" in decision["permissionDecisionReason"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_loaded_skill_allows_action_silently(tmp_path: Path) -> None:
|
|
62
|
+
path = _transcript(tmp_path, "plugin:pr-lifecycle")
|
|
63
|
+
assert _run_main(_payload("gh pr create", path)) == (0, "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_user_slash_command_loads_skill(tmp_path: Path) -> None:
|
|
67
|
+
path = tmp_path / "slash.jsonl"
|
|
68
|
+
path.write_text(json.dumps({"type": "user", "message": {"content": "<command-name>/pr-lifecycle</command-name>"}}) + "\n", encoding="utf-8")
|
|
69
|
+
assert _run_main(_payload("git commit", path)) == (0, "")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_compaction_requires_another_invocation(tmp_path: Path) -> None:
|
|
73
|
+
_assert_denied(_payload("git push", _transcript(tmp_path, "pr-lifecycle", compact=True)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_agent_transcript_can_supply_invocation(tmp_path: Path) -> None:
|
|
77
|
+
session_path = _transcript(tmp_path)
|
|
78
|
+
agent_path = tmp_path / "agent.jsonl"
|
|
79
|
+
agent_path.write_text(json.dumps({"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "pr-lifecycle"}}]}}) + "\n", encoding="utf-8")
|
|
80
|
+
payload = _payload("git push", session_path)
|
|
81
|
+
payload["agent_transcript_path"] = str(agent_path)
|
|
82
|
+
assert gate.decision_for(payload) is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_unreadable_or_missing_transcript_allows_silently(tmp_path: Path) -> None:
|
|
86
|
+
payload = _payload("gh pr create", tmp_path / "missing.jsonl")
|
|
87
|
+
assert _run_main(payload) == (0, "")
|
|
88
|
+
payload.pop("transcript_path")
|
|
89
|
+
assert _run_main(payload) == (0, "")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_action_command_shapes(tmp_path: Path) -> None:
|
|
93
|
+
path = _transcript(tmp_path)
|
|
94
|
+
commands = (
|
|
95
|
+
"git commit",
|
|
96
|
+
"git -C some/path commit -F message.txt",
|
|
97
|
+
"git --git-dir=.git push origin head",
|
|
98
|
+
"gh pr create",
|
|
99
|
+
"gh -R owner/repo pr --repo owner/repo merge",
|
|
100
|
+
"gh api -X PUT repos/owner/repo/pulls/1/merge",
|
|
101
|
+
"gh api graphql -f query='mutation { enablePullRequestAutoMerge }'",
|
|
102
|
+
"gh api graphql -f name=auto-merge",
|
|
103
|
+
"echo done; gh pr edit",
|
|
104
|
+
"GIT_TRACE=1 git push",
|
|
105
|
+
"env -u GH_TOKEN gh pr merge 1",
|
|
106
|
+
"env --unset=GH_TOKEN GH_HOST=github.com gh pr create",
|
|
107
|
+
"sudo git push",
|
|
108
|
+
"(git push)",
|
|
109
|
+
)
|
|
110
|
+
for command in commands:
|
|
111
|
+
_assert_denied(_payload(command, path, "PowerShell"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_non_action_command_shapes(tmp_path: Path) -> None:
|
|
115
|
+
path = _transcript(tmp_path)
|
|
116
|
+
commands = (
|
|
117
|
+
"git status",
|
|
118
|
+
"git commit-tree 123",
|
|
119
|
+
"git log --grep push",
|
|
120
|
+
"echo 'gh pr create'",
|
|
121
|
+
"gh api repos/owner/repo/pulls/1/mergeability",
|
|
122
|
+
"gh issue create --body-file message.txt",
|
|
123
|
+
"gh pr",
|
|
124
|
+
"GH_TOKEN=x gh issue list",
|
|
125
|
+
"env -u GH_TOKEN git status",
|
|
126
|
+
)
|
|
127
|
+
for command in commands:
|
|
128
|
+
assert gate.decision_for(_payload(command, path)) is None, command
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_matching_github_mcp_tools_are_denied(tmp_path: Path) -> None:
|
|
132
|
+
path = _transcript(tmp_path)
|
|
133
|
+
for suffix in ("create_pull_request", "merge_pull_request", "enable_pr_auto_merge", "update_pull_request"):
|
|
134
|
+
payload = _payload("", path, "mcp__github__" + suffix)
|
|
135
|
+
_assert_denied(payload)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_malformed_payload_exits_zero_silently() -> None:
|
|
139
|
+
assert _run_main("bad json") == (0, "")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_hook_has_its_own_pre_tool_use_registration() -> None:
|
|
143
|
+
registration_path = HOOKS_DIRECTORY / "hooks.json"
|
|
144
|
+
all_registration_fields = json.loads(registration_path.read_text(encoding="utf-8"))
|
|
145
|
+
all_groups = all_registration_fields["hooks"]["PreToolUse"]
|
|
146
|
+
matching_groups = [
|
|
147
|
+
each_group
|
|
148
|
+
for each_group in all_groups
|
|
149
|
+
if each_group["matcher"]
|
|
150
|
+
== "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)"
|
|
151
|
+
]
|
|
152
|
+
assert matching_groups == [
|
|
153
|
+
{
|
|
154
|
+
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
|
|
155
|
+
"hooks": [
|
|
156
|
+
{
|
|
157
|
+
"type": "command",
|
|
158
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_lifecycle_skill_gate.py",
|
|
159
|
+
"timeout": 10,
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
]
|
package/hooks/hooks.json
CHANGED
|
@@ -47,6 +47,16 @@
|
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
|
|
52
|
+
"hooks": [
|
|
53
|
+
{
|
|
54
|
+
"type": "command",
|
|
55
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_lifecycle_skill_gate.py",
|
|
56
|
+
"timeout": 10
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
50
60
|
{
|
|
51
61
|
"matcher": "EnterWorktree",
|
|
52
62
|
"hooks": [
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Constants for the pull request lifecycle skill gate."""
|
|
2
|
+
|
|
3
|
+
HOOK_EVENT_NAME = "PreToolUse"
|
|
4
|
+
HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
|
|
5
|
+
PERMISSION_DECISION_KEY = "permissionDecision"
|
|
6
|
+
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
7
|
+
DENY_DECISION = "deny"
|
|
8
|
+
DENY_REASON = (
|
|
9
|
+
"Invoke the pr-lifecycle skill with the Skill tool, then run the same command again."
|
|
10
|
+
)
|
|
11
|
+
SKILL_NAME = "pr-lifecycle"
|
|
12
|
+
ALL_SLASH_COMMAND_MARKERS = ("<command-name>/pr-lifecycle</command-name>",)
|
|
13
|
+
SHELL_TOOL_NAMES = frozenset({"Bash", "PowerShell"})
|
|
14
|
+
ALL_GITHUB_MCP_TOOL_SUFFIXES = (
|
|
15
|
+
"__create_pull_request",
|
|
16
|
+
"__merge_pull_request",
|
|
17
|
+
"__enable_pr_auto_merge",
|
|
18
|
+
"__update_pull_request",
|
|
19
|
+
)
|
|
20
|
+
ALL_TRANSCRIPT_PATH_FIELDS = ("transcript_path", "agent_transcript_path")
|
|
21
|
+
COMMAND_SEPARATORS = ";|&\n()"
|
|
22
|
+
COMMAND_WHITESPACE = " \t\r"
|
|
23
|
+
OPTION_AND_VALUE_WORD_COUNT = 2
|
|
24
|
+
GIT_GLOBAL_OPTIONS_WITH_VALUE = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"})
|
|
25
|
+
GH_OPTIONS_WITH_VALUE = frozenset({"-R", "--repo", "--hostname", "--config", "-H", "--header", "-X", "--method", "-F", "--field", "-f", "--raw-field", "--input", "--jq", "-q", "--template", "-t", "--cache"})
|
|
26
|
+
GIT_ACTIONS = frozenset({"commit", "push"})
|
|
27
|
+
GH_PULL_REQUEST_COMMAND = "pr"
|
|
28
|
+
GH_API_COMMAND = "api"
|
|
29
|
+
MERGE_PATH_SUFFIX = "/merge"
|
|
30
|
+
ALL_API_ACTION_NAMES = ("auto-merge", "enablePullRequestAutoMerge")
|
|
31
|
+
ALL_COMMAND_PREFIX_WORDS = frozenset({"env", "sudo", "command", "exec", "nohup", "time"})
|
|
32
|
+
ALL_PREFIX_OPTIONS_WITH_VALUE = frozenset({"-u", "--unset", "-C", "--chdir", "-g", "--group", "--user"})
|
|
33
|
+
ENVIRONMENT_ASSIGNMENT_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]*="
|
|
@@ -4,8 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
6
|
"ALL_SELF_LOADING_SUBAGENT_TYPES",
|
|
7
|
-
"ASSISTANT_ENTRY_TYPE",
|
|
8
|
-
"COMPACT_BOUNDARY_SUBTYPE",
|
|
9
7
|
"COMPACTION_REMINDER",
|
|
10
8
|
"COMPACTION_SOURCE",
|
|
11
9
|
"NOT_LOADED_REMINDER",
|
|
@@ -13,14 +11,11 @@ __all__ = [
|
|
|
13
11
|
"PRE_TOOL_USE_EVENT_NAME",
|
|
14
12
|
"PROMPT_SEPARATOR",
|
|
15
13
|
"SESSION_START_EVENT_NAME",
|
|
16
|
-
"SKILL_TOOL_NAME",
|
|
17
14
|
"ALL_SLASH_COMMAND_MARKERS",
|
|
18
15
|
"SUBAGENT_START_EVENT_NAME",
|
|
19
16
|
"CLAUDE_SUBAGENT_PROMPT_PREFIX",
|
|
20
17
|
"CODEX_SUBAGENT_PROMPT_PREFIX",
|
|
21
18
|
"ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME",
|
|
22
|
-
"TOOL_USE_BLOCK_TYPE",
|
|
23
|
-
"USER_ENTRY_TYPE",
|
|
24
19
|
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
25
20
|
"WORKFLOW_SUBAGENT_TYPE",
|
|
26
21
|
]
|
|
@@ -42,11 +37,6 @@ ALL_SLASH_COMMAND_MARKERS = (
|
|
|
42
37
|
)
|
|
43
38
|
PROMPT_SEPARATOR = "\n\n"
|
|
44
39
|
|
|
45
|
-
ASSISTANT_ENTRY_TYPE = "assistant"
|
|
46
|
-
USER_ENTRY_TYPE = "user"
|
|
47
|
-
TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
48
|
-
COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
|
|
49
|
-
|
|
50
40
|
CLAUDE_SUBAGENT_PROMPT_PREFIX = (
|
|
51
41
|
"Before any other work, invoke the poteto-mode skill with the Skill tool. "
|
|
52
42
|
"Every prompt you write for a subagent opens with the same instruction."
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from hooks_constants import pr_lifecycle_skill_gate_constants as constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_gate_targets_the_named_skill_and_pre_tool_use() -> None:
|
|
5
|
+
assert constants.SKILL_NAME == "pr-lifecycle"
|
|
6
|
+
assert constants.HOOK_EVENT_NAME == "PreToolUse"
|
|
7
|
+
assert constants.DENY_DECISION == "deny"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_gate_covers_shells_and_pull_request_tools() -> None:
|
|
11
|
+
assert constants.SHELL_TOOL_NAMES == {"Bash", "PowerShell"}
|
|
12
|
+
assert len(constants.ALL_GITHUB_MCP_TOOL_SUFFIXES) == 4
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from hooks_constants import transcript_skill_scan_constants as constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_transcript_entry_names() -> None:
|
|
5
|
+
assert constants.COMPACT_BOUNDARY_SUBTYPE == "compact_boundary"
|
|
6
|
+
assert constants.SKILL_TOOL_NAME == "Skill"
|
|
7
|
+
assert constants.TOOL_USE_BLOCK_TYPE == "tool_use"
|
|
@@ -29,8 +29,6 @@ if _hooks_dir not in sys.path:
|
|
|
29
29
|
|
|
30
30
|
from hooks_constants.skill_loaded_reminder_constants import (
|
|
31
31
|
ALL_SELF_LOADING_SUBAGENT_TYPES,
|
|
32
|
-
ASSISTANT_ENTRY_TYPE,
|
|
33
|
-
COMPACT_BOUNDARY_SUBTYPE,
|
|
34
32
|
COMPACTION_REMINDER,
|
|
35
33
|
COMPACTION_SOURCE,
|
|
36
34
|
NOT_LOADED_REMINDER,
|
|
@@ -38,18 +36,16 @@ from hooks_constants.skill_loaded_reminder_constants import (
|
|
|
38
36
|
PRE_TOOL_USE_EVENT_NAME,
|
|
39
37
|
PROMPT_SEPARATOR,
|
|
40
38
|
SESSION_START_EVENT_NAME,
|
|
41
|
-
SKILL_TOOL_NAME,
|
|
42
39
|
ALL_SLASH_COMMAND_MARKERS,
|
|
43
40
|
SUBAGENT_START_EVENT_NAME,
|
|
44
41
|
ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME,
|
|
45
|
-
TOOL_USE_BLOCK_TYPE,
|
|
46
|
-
USER_ENTRY_TYPE,
|
|
47
42
|
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
48
43
|
WORKFLOW_SUBAGENT_TYPE,
|
|
49
44
|
)
|
|
50
45
|
from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
|
|
51
46
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
52
47
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
48
|
+
from transcript_skill_scan import skill_invocation_status
|
|
53
49
|
|
|
54
50
|
|
|
55
51
|
def subagent_input_with_poteto_mode(
|
|
@@ -81,44 +77,6 @@ def subagent_input_with_poteto_mode(
|
|
|
81
77
|
return {**all_tool_input_fields, field_name: invocation_prefix + PROMPT_SEPARATOR + prompt}
|
|
82
78
|
|
|
83
79
|
|
|
84
|
-
def _invokes_poteto_mode(all_entry_fields: dict[str, object]) -> bool:
|
|
85
|
-
message = all_entry_fields.get("message")
|
|
86
|
-
if not isinstance(message, dict):
|
|
87
|
-
return False
|
|
88
|
-
content_blocks = message.get("content")
|
|
89
|
-
if all_entry_fields.get("type") == USER_ENTRY_TYPE and isinstance(content_blocks, str):
|
|
90
|
-
return any(each_marker in content_blocks for each_marker in ALL_SLASH_COMMAND_MARKERS)
|
|
91
|
-
if all_entry_fields.get("type") != ASSISTANT_ENTRY_TYPE or not isinstance(content_blocks, list):
|
|
92
|
-
return False
|
|
93
|
-
return any(
|
|
94
|
-
isinstance(each_block, dict)
|
|
95
|
-
and each_block.get("type") == TOOL_USE_BLOCK_TYPE
|
|
96
|
-
and each_block.get("name") == SKILL_TOOL_NAME
|
|
97
|
-
and isinstance(each_block.get("input"), dict)
|
|
98
|
-
and each_block["input"].get("skill") in ALL_POTETO_MODE_SKILL_NAMES
|
|
99
|
-
for each_block in content_blocks
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def _marker_entry(transcript_line: str) -> dict[str, object] | None:
|
|
104
|
-
if (
|
|
105
|
-
COMPACT_BOUNDARY_SUBTYPE not in transcript_line
|
|
106
|
-
and ALL_POTETO_MODE_SKILL_NAMES[0] not in transcript_line
|
|
107
|
-
):
|
|
108
|
-
return None
|
|
109
|
-
try:
|
|
110
|
-
parsed_entry = json.loads(transcript_line)
|
|
111
|
-
except json.JSONDecodeError:
|
|
112
|
-
return None
|
|
113
|
-
return parsed_entry if isinstance(parsed_entry, dict) else None
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _is_loaded_after(all_entry_fields: dict[str, object], was_loaded: bool) -> bool:
|
|
117
|
-
if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE:
|
|
118
|
-
return False
|
|
119
|
-
return was_loaded or _invokes_poteto_mode(all_entry_fields)
|
|
120
|
-
|
|
121
|
-
|
|
122
80
|
def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]:
|
|
123
81
|
"""Report whether the skill was invoked and whether it remains loaded.
|
|
124
82
|
|
|
@@ -139,14 +97,9 @@ def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]
|
|
|
139
97
|
Returns:
|
|
140
98
|
Whether the skill was invoked at any point and whether it is loaded now.
|
|
141
99
|
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
all_entry_fields = _marker_entry(each_line)
|
|
146
|
-
if all_entry_fields is not None:
|
|
147
|
-
is_loaded = _is_loaded_after(all_entry_fields, is_loaded)
|
|
148
|
-
was_invoked = was_invoked or is_loaded
|
|
149
|
-
return was_invoked, is_loaded
|
|
100
|
+
return skill_invocation_status(
|
|
101
|
+
all_transcript_lines, ALL_POTETO_MODE_SKILL_NAMES, ALL_SLASH_COMMAND_MARKERS
|
|
102
|
+
)
|
|
150
103
|
|
|
151
104
|
|
|
152
105
|
def is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
|
|
@@ -200,6 +200,10 @@ class TestIsPotetoModeLoaded:
|
|
|
200
200
|
prefixed_command_line = TYPED_COMMAND_LINE.replace("/poteto-mode", "/pstack:poteto-mode")
|
|
201
201
|
assert reminder.is_poteto_mode_loaded([prefixed_command_line])
|
|
202
202
|
|
|
203
|
+
def test_a_different_skill_call_does_not_load_it(self) -> None:
|
|
204
|
+
other_skill_call_line = SKILL_CALL_LINE.replace("poteto-mode", "pr-lifecycle")
|
|
205
|
+
assert not reminder.is_poteto_mode_loaded([other_skill_call_line])
|
|
206
|
+
|
|
203
207
|
def test_a_compaction_after_the_skill_call_drops_it(self) -> None:
|
|
204
208
|
assert not reminder.is_poteto_mode_loaded(
|
|
205
209
|
[SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Tests for skill invocation state across transcript compactions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent
|
|
8
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
10
|
+
|
|
11
|
+
from transcript_skill_scan import is_skill_loaded_after_last_compaction, skill_invocation_status
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _skill_entry(name: str) -> str:
|
|
15
|
+
return json.dumps(
|
|
16
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": name}}]}}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_matching_skill_after_compaction_loads() -> None:
|
|
21
|
+
lines = [
|
|
22
|
+
_skill_entry("pr-lifecycle"),
|
|
23
|
+
json.dumps({"type": "system", "subtype": "compact_boundary"}),
|
|
24
|
+
_skill_entry("plugin:pr-lifecycle"),
|
|
25
|
+
]
|
|
26
|
+
assert is_skill_loaded_after_last_compaction(
|
|
27
|
+
lines, ("pr-lifecycle",), ("<command-name>/pr-lifecycle</command-name>",)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_compaction_drops_a_loaded_skill() -> None:
|
|
32
|
+
lines = [_skill_entry("pr-lifecycle"), json.dumps({"subtype": "compact_boundary"})]
|
|
33
|
+
assert not is_skill_loaded_after_last_compaction(lines, ("pr-lifecycle",), ())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_user_command_loads_and_quoted_tool_result_does_not() -> None:
|
|
37
|
+
marker = "<command-name>/pr-lifecycle</command-name>"
|
|
38
|
+
user_entry = json.dumps({"type": "user", "message": {"content": marker}})
|
|
39
|
+
quoted_result = json.dumps(
|
|
40
|
+
{"type": "user", "message": {"content": [{"type": "tool_result", "content": marker}]}}
|
|
41
|
+
)
|
|
42
|
+
assert is_skill_loaded_after_last_compaction([user_entry], ("pr-lifecycle",), (marker,))
|
|
43
|
+
assert not is_skill_loaded_after_last_compaction([quoted_result], ("pr-lifecycle",), (marker,))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_malformed_entries_do_not_load() -> None:
|
|
47
|
+
assert not is_skill_loaded_after_last_compaction(
|
|
48
|
+
["pr-lifecycle {", json.dumps({"type": "assistant", "message": {"content": "pr-lifecycle"}})],
|
|
49
|
+
("pr-lifecycle",),
|
|
50
|
+
(),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_invocation_status_separates_ever_invoked_from_loaded_now() -> None:
|
|
55
|
+
compaction = json.dumps({"subtype": "compact_boundary"})
|
|
56
|
+
assert skill_invocation_status([_skill_entry("pr-lifecycle")], ("pr-lifecycle",), ()) == (True, True)
|
|
57
|
+
assert skill_invocation_status([_skill_entry("pr-lifecycle"), compaction], ("pr-lifecycle",), ()) == (True, False)
|
|
58
|
+
assert skill_invocation_status([compaction, _skill_entry("other")], ("pr-lifecycle",), ()) == (False, False)
|