claude-dev-env 4.1.0 → 4.3.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/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
- package/.agents/skills/everything-search/SKILL.md +18 -3
- package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
- package/.agents/skills/issue-tracker/SKILL.md +6 -0
- package/.agents/skills/pull-request/SKILL.md +218 -0
- package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
- package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
- package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
- package/.agents/skills/test-runner/SKILL.md +69 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
- package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/install.mjs +9 -1
- package/bin/install.prune.test.mjs +149 -0
- package/bin/install.test.mjs +24 -7
- package/hooks/blocking/pii_prevention_blocker.py +32 -51
- package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
- package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
- package/hooks/gh_post_body_texts.py +195 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
- package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
- package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
- package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
- package/hooks/observability/test_failure_recorder.py +1 -1
- package/hooks/pyproject.toml +1 -1
- package/hooks/test_gh_post_body_texts.py +79 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +31 -20
- package/rules/filesystem-search.md +5 -5
- package/rules/gh-cli-conventions.md +23 -13
- package/rules/git-workflow.md +21 -8
- package/scripts/policy_lint/adapter_configuration.py +53 -12
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/tests/test_adapter_configuration.py +54 -0
- package/hooks/_gh_pr_author_swap_utils.py +0 -1225
- package/hooks/blocking/conventional_pr_title_gate.py +0 -443
- package/hooks/blocking/es_exe_path_rewriter.py +0 -159
- package/hooks/blocking/gh_body_arg_blocker.py +0 -176
- package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
- package/hooks/blocking/gh_pr_author_restore.py +0 -105
- package/hooks/blocking/pr_description_writer_gate.py +0 -256
- package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
- package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
- package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
- package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
- package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
- package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
- package/hooks/blocking/test_preflight_check.py +0 -343
- package/hooks/blocking/test_test_preflight_check.py +0 -80
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
- package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
- package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
- package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
- package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
- package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
- package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
- package/hooks/test__gh_pr_author_swap_utils.py +0 -333
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Pin tests for path_rewriter_constants — values consumed by es_exe_path_rewriter."""
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
7
|
-
if str(_HOOKS_ROOT) not in sys.path:
|
|
8
|
-
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
9
|
-
|
|
10
|
-
from hooks_constants.path_rewriter_constants import (
|
|
11
|
-
BASH_TOOL_NAME,
|
|
12
|
-
HOOK_EVENT_NAME,
|
|
13
|
-
PERMISSION_ALLOW,
|
|
14
|
-
PLACEHOLDER_TOKEN_PATTERN,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_bash_tool_name_is_bash() -> None:
|
|
19
|
-
assert BASH_TOOL_NAME == "Bash"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_hook_event_name_is_pre_tool_use() -> None:
|
|
23
|
-
assert HOOK_EVENT_NAME == "PreToolUse"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_permission_allow_is_allow() -> None:
|
|
27
|
-
assert PERMISSION_ALLOW == "allow"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_placeholder_token_pattern_matches_curly_brace_form() -> None:
|
|
31
|
-
match = PLACEHOLDER_TOKEN_PATTERN.match("{my-repo}")
|
|
32
|
-
assert match is not None
|
|
33
|
-
assert match.group(1) == "my-repo"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_placeholder_token_pattern_matches_double_quoted_form() -> None:
|
|
37
|
-
match = PLACEHOLDER_TOKEN_PATTERN.match('"{my-repo}"')
|
|
38
|
-
assert match is not None
|
|
39
|
-
assert match.group(1) == "my-repo"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_placeholder_token_pattern_matches_single_quoted_form() -> None:
|
|
43
|
-
match = PLACEHOLDER_TOKEN_PATTERN.match("'{my-repo}'")
|
|
44
|
-
assert match is not None
|
|
45
|
-
assert match.group(1) == "my-repo"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_placeholder_token_pattern_does_not_match_shell_parameter_expansion() -> None:
|
|
49
|
-
assert PLACEHOLDER_TOKEN_PATTERN.search("${myrepo}") is None
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_placeholder_token_pattern_does_not_match_embedded_in_flag() -> None:
|
|
53
|
-
assert PLACEHOLDER_TOKEN_PATTERN.search("--flag={my-repo}") is None
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_placeholder_token_pattern_does_not_match_embedded_in_token() -> None:
|
|
57
|
-
assert PLACEHOLDER_TOKEN_PATTERN.search("foo{my-repo}bar") is None
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Configuration constants for the volatile_path_in_post_blocker PreToolUse hook."""
|
|
2
|
-
|
|
3
|
-
BASH_TOOL_NAME: str = "Bash"
|
|
4
|
-
|
|
5
|
-
MCP_GITHUB_TOOL_PREFIX: str = "mcp__plugin_github_github__"
|
|
6
|
-
|
|
7
|
-
ALL_MCP_BODY_PARAM_NAMES: tuple[str, ...] = ("body", "comment")
|
|
8
|
-
|
|
9
|
-
GH_COMMAND_NAME: str = "gh"
|
|
10
|
-
|
|
11
|
-
MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT: int = 2
|
|
12
|
-
|
|
13
|
-
TOKEN_JOIN_SEPARATOR: str = " "
|
|
14
|
-
|
|
15
|
-
BODY_FILE_ENCODING: str = "utf-8"
|
|
16
|
-
|
|
17
|
-
ALL_GH_POST_SUBCOMMANDS: dict[str, frozenset[str]] = {
|
|
18
|
-
"pr": frozenset({"create", "comment", "edit", "review"}),
|
|
19
|
-
"issue": frozenset({"create", "comment", "edit"}),
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
PATH_ANCHOR_CHARACTER: str = "/"
|
|
23
|
-
|
|
24
|
-
PATH_SEGMENT_START_CHARACTERS: str = "_-"
|
|
25
|
-
|
|
26
|
-
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
|
|
27
|
-
".claude-profile-a/jobs/",
|
|
28
|
-
".claude/worktrees/",
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
ALL_BARE_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
|
|
32
|
-
"appdata/local/temp",
|
|
33
|
-
"/tmp/",
|
|
34
|
-
"%temp%",
|
|
35
|
-
"$env:temp",
|
|
36
|
-
"$claude_job_dir",
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
GH_ARTIFACT_UPLOAD_INVOCATION: str = (
|
|
40
|
-
"python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>"
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
CORRECTIVE_MESSAGE: str = (
|
|
44
|
-
"BLOCKED [durable-post-artifacts]: this post body references a volatile path "
|
|
45
|
-
"(a job scratch dir, worktree, or system temp location). The post is durable "
|
|
46
|
-
"and outlives that scratch, so the reference breaks the moment the directory "
|
|
47
|
-
"is cleaned.\n\n"
|
|
48
|
-
"Fix it before posting:\n"
|
|
49
|
-
" 1. Text data (logs, tables, diffs): paste the actual content inline in the "
|
|
50
|
-
"post instead of linking a scratch file path.\n"
|
|
51
|
-
" 2. Binary artifacts (images, archives): upload the file to the repo's "
|
|
52
|
-
"durable 'artifacts' release and link the permanent asset URL it prints:\n"
|
|
53
|
-
f" {GH_ARTIFACT_UPLOAD_INVOCATION}\n\n"
|
|
54
|
-
"See ~/.claude/rules/durable-post-artifacts.md for the full contract."
|
|
55
|
-
)
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PostToolUse hook: record that this session spawned the pr-description-writer agent.
|
|
3
|
-
|
|
4
|
-
Picture opening a pull request whose body nobody wrote from the diff — it reads
|
|
5
|
-
like a commit message, and the reviewer learns nothing before opening the files.
|
|
6
|
-
The ``pr-description-writer`` agent exists to write that body, and it hands the
|
|
7
|
-
markdown and a body-file path back to the caller to publish.
|
|
8
|
-
|
|
9
|
-
This hook remembers that the agent ran. After each Agent or Task spawn naming
|
|
10
|
-
``pr-description-writer``, it touches an empty marker file for this session in
|
|
11
|
-
the system temp directory. The ``gh pr create`` gate reads that marker.
|
|
12
|
-
|
|
13
|
-
The marker carries one fact — the agent ran this session — so a touch records
|
|
14
|
-
everything the gate needs, and two parallel spawns racing on the same touch
|
|
15
|
-
leave the same result. The file name carries the session id, so a marker a
|
|
16
|
-
crashed session leaves behind is inert: no later session reads it.
|
|
17
|
-
|
|
18
|
-
The hook never blocks a tool call. A tool that is not a spawn, a spawn of
|
|
19
|
-
another agent, a malformed payload, a payload naming no session, or a failed
|
|
20
|
-
touch each returns quietly.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import sys
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
|
|
28
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
29
|
-
if _hooks_dir not in sys.path:
|
|
30
|
-
sys.path.insert(0, _hooks_dir)
|
|
31
|
-
|
|
32
|
-
from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
|
|
33
|
-
ALL_AGENT_SPAWN_TOOL_NAMES,
|
|
34
|
-
PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
|
|
35
|
-
SUBAGENT_TYPE_FIELD_NAME,
|
|
36
|
-
TOOL_INPUT_FIELD_NAME,
|
|
37
|
-
TOOL_NAME_FIELD_NAME,
|
|
38
|
-
)
|
|
39
|
-
from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
|
|
40
|
-
spawn_marker_path as shared_spawn_marker_path,
|
|
41
|
-
)
|
|
42
|
-
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
43
|
-
read_hook_input_dictionary_from_stdin,
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def spawn_marker_path(session_id: str, temp_directory: Path | None = None) -> Path | None:
|
|
48
|
-
"""Return the marker path for one session, or None without a usable id.
|
|
49
|
-
|
|
50
|
-
::
|
|
51
|
-
|
|
52
|
-
session_id = "abc-123" -> <temp>/claude-session-pr-description-writer-abc-123.marker
|
|
53
|
-
session_id = "" -> None
|
|
54
|
-
session_id = "///" -> None
|
|
55
|
-
|
|
56
|
-
Unsafe characters are stripped so the name stays anchored inside the temp
|
|
57
|
-
directory. A session id that sanitizes to nothing yields no path, so every
|
|
58
|
-
marker on disk belongs to one real session and a marker a crashed session
|
|
59
|
-
leaves behind is inert — no later session reads it.
|
|
60
|
-
|
|
61
|
-
Args:
|
|
62
|
-
session_id: Raw ``session_id`` from the hook payload.
|
|
63
|
-
temp_directory: The directory holding the marker. Defaults to the
|
|
64
|
-
system temp directory.
|
|
65
|
-
|
|
66
|
-
Returns:
|
|
67
|
-
Absolute path to this session's marker file, or None when the payload
|
|
68
|
-
names no session this hook can key on.
|
|
69
|
-
"""
|
|
70
|
-
return shared_spawn_marker_path(session_id, temp_directory)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _record_spawn(session_id: str) -> None:
|
|
74
|
-
"""Touch this session's marker file, ignoring a write failure.
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
session_id: Raw ``session_id`` from the hook payload.
|
|
78
|
-
"""
|
|
79
|
-
marker_file = spawn_marker_path(session_id)
|
|
80
|
-
if marker_file is None:
|
|
81
|
-
return
|
|
82
|
-
try:
|
|
83
|
-
marker_file.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
-
marker_file.touch(exist_ok=True)
|
|
85
|
-
except OSError:
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def main() -> None:
|
|
90
|
-
"""Record the marker when this payload is a pr-description-writer spawn.
|
|
91
|
-
|
|
92
|
-
Reads the PostToolUse payload from stdin and touches this session's marker
|
|
93
|
-
file when an Agent or Task spawn names the ``pr-description-writer`` agent.
|
|
94
|
-
Returns on every other branch — another tool, another agent, a malformed
|
|
95
|
-
payload, or a missing ``tool_input`` — so the spawn is never blocked.
|
|
96
|
-
"""
|
|
97
|
-
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
98
|
-
if hook_payload is None:
|
|
99
|
-
return
|
|
100
|
-
tool_name = hook_payload.get(TOOL_NAME_FIELD_NAME, "")
|
|
101
|
-
if tool_name not in ALL_AGENT_SPAWN_TOOL_NAMES:
|
|
102
|
-
return
|
|
103
|
-
tool_input = hook_payload.get(TOOL_INPUT_FIELD_NAME, {})
|
|
104
|
-
if not isinstance(tool_input, dict):
|
|
105
|
-
return
|
|
106
|
-
if tool_input.get(SUBAGENT_TYPE_FIELD_NAME) != PR_DESCRIPTION_WRITER_SUBAGENT_TYPE:
|
|
107
|
-
return
|
|
108
|
-
session_id = str(hook_payload.get("session_id") or "")
|
|
109
|
-
_record_spawn(session_id)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if __name__ == "__main__":
|
|
113
|
-
main()
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
"""Unit tests for the pr-description-writer spawn tracker PostToolUse hook."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import importlib.util
|
|
6
|
-
import io
|
|
7
|
-
import json
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from unittest import mock
|
|
11
|
-
|
|
12
|
-
import pytest
|
|
13
|
-
|
|
14
|
-
_OBSERVABILITY_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
-
_HOOKS_DIRECTORY = _OBSERVABILITY_DIRECTORY.parent
|
|
16
|
-
for each_directory in (_OBSERVABILITY_DIRECTORY, _HOOKS_DIRECTORY):
|
|
17
|
-
if str(each_directory) not in sys.path:
|
|
18
|
-
sys.path.insert(0, str(each_directory))
|
|
19
|
-
|
|
20
|
-
_TRACKER_SPEC = importlib.util.spec_from_file_location(
|
|
21
|
-
"pr_description_writer_spawn_tracker",
|
|
22
|
-
_OBSERVABILITY_DIRECTORY / "pr_description_writer_spawn_tracker.py",
|
|
23
|
-
)
|
|
24
|
-
assert _TRACKER_SPEC is not None
|
|
25
|
-
assert _TRACKER_SPEC.loader is not None
|
|
26
|
-
tracker_module = importlib.util.module_from_spec(_TRACKER_SPEC)
|
|
27
|
-
_TRACKER_SPEC.loader.exec_module(tracker_module)
|
|
28
|
-
|
|
29
|
-
_GATE_CONSTANTS = importlib.import_module(
|
|
30
|
-
"hooks_constants.pr_description_writer_gate_constants"
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
_SESSION_ID = "session-abc123"
|
|
34
|
-
_OTHER_SUBAGENT_TYPE = "general-purpose"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _spawn_payload(
|
|
38
|
-
subagent_type: str,
|
|
39
|
-
tool_name: str = "Agent",
|
|
40
|
-
session_id: str = _SESSION_ID,
|
|
41
|
-
) -> dict[str, object]:
|
|
42
|
-
"""Build a PostToolUse payload for one agent spawn.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
subagent_type: The agent type the spawn names.
|
|
46
|
-
tool_name: The spawning tool name.
|
|
47
|
-
session_id: The session the spawn belongs to.
|
|
48
|
-
|
|
49
|
-
Returns:
|
|
50
|
-
The payload dictionary the tracker reads from stdin.
|
|
51
|
-
"""
|
|
52
|
-
return {
|
|
53
|
-
"tool_name": tool_name,
|
|
54
|
-
"session_id": session_id,
|
|
55
|
-
"tool_input": {
|
|
56
|
-
"subagent_type": subagent_type,
|
|
57
|
-
"description": "Write the PR body",
|
|
58
|
-
"prompt": "Write a PR description from the diff.",
|
|
59
|
-
},
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _run_tracker(payload: dict[str, object], temp_directory: Path) -> None:
|
|
64
|
-
"""Run the tracker's main against a payload with temp redirected.
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
payload: The PostToolUse payload to feed on stdin.
|
|
68
|
-
temp_directory: The directory standing in for the system temp root.
|
|
69
|
-
"""
|
|
70
|
-
with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))):
|
|
71
|
-
with mock.patch.object(
|
|
72
|
-
_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(temp_directory)
|
|
73
|
-
):
|
|
74
|
-
tracker_module.main()
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def should_record_marker_when_pr_description_writer_spawns(tmp_path: Path) -> None:
|
|
78
|
-
"""A pr-description-writer spawn leaves this session's marker on disk."""
|
|
79
|
-
_run_tracker(_spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE), tmp_path)
|
|
80
|
-
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def should_leave_no_marker_when_another_agent_spawns(tmp_path: Path) -> None:
|
|
84
|
-
"""A spawn of a different agent type records nothing."""
|
|
85
|
-
_run_tracker(_spawn_payload(_OTHER_SUBAGENT_TYPE), tmp_path)
|
|
86
|
-
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@pytest.mark.parametrize("spawn_tool_name", ["Agent", "Task"])
|
|
90
|
-
def should_record_marker_for_each_spawn_tool_name(spawn_tool_name: str, tmp_path: Path) -> None:
|
|
91
|
-
"""Both spawning tool names record the marker."""
|
|
92
|
-
payload = _spawn_payload(
|
|
93
|
-
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name=spawn_tool_name
|
|
94
|
-
)
|
|
95
|
-
_run_tracker(payload, tmp_path)
|
|
96
|
-
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def should_leave_no_marker_for_an_unrelated_tool(tmp_path: Path) -> None:
|
|
100
|
-
"""A tool that is not a spawn records nothing, even naming the agent."""
|
|
101
|
-
payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name="Bash")
|
|
102
|
-
_run_tracker(payload, tmp_path)
|
|
103
|
-
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def should_keep_each_session_marker_separate(tmp_path: Path) -> None:
|
|
107
|
-
"""A spawn in one session leaves another session's marker absent."""
|
|
108
|
-
payload = _spawn_payload(
|
|
109
|
-
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, session_id="session-one"
|
|
110
|
-
)
|
|
111
|
-
_run_tracker(payload, tmp_path)
|
|
112
|
-
assert tracker_module.spawn_marker_path("session-one", tmp_path).exists() is True
|
|
113
|
-
assert tracker_module.spawn_marker_path("session-two", tmp_path).exists() is False
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def should_record_twice_without_error(tmp_path: Path) -> None:
|
|
117
|
-
"""A second spawn in one session leaves the marker in place."""
|
|
118
|
-
payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE)
|
|
119
|
-
_run_tracker(payload, tmp_path)
|
|
120
|
-
_run_tracker(payload, tmp_path)
|
|
121
|
-
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def should_ignore_a_malformed_payload(tmp_path: Path) -> None:
|
|
125
|
-
"""Unparseable stdin records nothing and raises nothing."""
|
|
126
|
-
with mock.patch("sys.stdin", io.StringIO("this is not json")):
|
|
127
|
-
with mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(tmp_path)):
|
|
128
|
-
tracker_module.main()
|
|
129
|
-
assert list(tmp_path.iterdir()) == []
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def should_ignore_a_payload_with_no_tool_input(tmp_path: Path) -> None:
|
|
133
|
-
"""A spawn payload missing tool_input records nothing."""
|
|
134
|
-
payload: dict[str, object] = {"tool_name": "Agent", "session_id": _SESSION_ID}
|
|
135
|
-
_run_tracker(payload, tmp_path)
|
|
136
|
-
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
@pytest.mark.parametrize("unusable_session_id", ["", "///"])
|
|
140
|
-
def should_record_nothing_without_a_usable_session_id(
|
|
141
|
-
unusable_session_id: str, tmp_path: Path
|
|
142
|
-
) -> None:
|
|
143
|
-
"""A payload naming no usable session records no marker at all."""
|
|
144
|
-
payload = _spawn_payload(
|
|
145
|
-
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
|
|
146
|
-
session_id=unusable_session_id,
|
|
147
|
-
)
|
|
148
|
-
_run_tracker(payload, tmp_path)
|
|
149
|
-
assert tracker_module.spawn_marker_path(unusable_session_id, tmp_path) is None
|
|
150
|
-
assert list(tmp_path.iterdir()) == []
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""SessionStart hook — sweep stale gh-pr-author swap state files at session start.
|
|
3
|
-
|
|
4
|
-
The PreToolUse enforcer (``gh_pr_author_enforcer.py``) writes a per-session
|
|
5
|
-
state file recording the original gh CLI account before swapping to
|
|
6
|
-
``GITHUB_DEFAULT_ACCOUNT``. The PostToolUse companion
|
|
7
|
-
(``gh_pr_author_restore.py``) reads that file and switches back when
|
|
8
|
-
``gh pr create`` finishes. When a session is interrupted between the
|
|
9
|
-
swap and the restore — a crash, a downstream PreToolUse deny that fires
|
|
10
|
-
*after* the enforcer's swap completed, or any other path that skips
|
|
11
|
-
PostToolUse — the user is left on ``GITHUB_DEFAULT_ACCOUNT`` with a
|
|
12
|
-
stale state file on disk.
|
|
13
|
-
|
|
14
|
-
This hook runs at the start of every Claude Code session. When
|
|
15
|
-
``GITHUB_DEFAULT_ACCOUNT`` is set, it scans ``tempfile.gettempdir()``
|
|
16
|
-
for every file matching ``{STATE_FILE_PREFIX}*{STATE_FILE_SUFFIX}``,
|
|
17
|
-
reads the original account from each, runs ``gh auth switch --user
|
|
18
|
-
<original>``, and deletes the file. A state file whose switch fails is
|
|
19
|
-
left in place so the next session can retry. The hook is a strict no-op
|
|
20
|
-
when ``GITHUB_DEFAULT_ACCOUNT`` is unset, so users who have not opted
|
|
21
|
-
into the swap workflow are completely unaffected.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import os
|
|
27
|
-
import sys
|
|
28
|
-
import tempfile
|
|
29
|
-
import time
|
|
30
|
-
from pathlib import Path
|
|
31
|
-
|
|
32
|
-
hooks_parent_directory = str(Path(__file__).resolve().parent.parent)
|
|
33
|
-
if hooks_parent_directory not in sys.path:
|
|
34
|
-
sys.path.insert(0, hooks_parent_directory)
|
|
35
|
-
|
|
36
|
-
from _gh_pr_author_swap_utils import ( # noqa: E402
|
|
37
|
-
_build_restore_failure_message,
|
|
38
|
-
_delete_state_file,
|
|
39
|
-
_lstat_indicates_attacker_planted,
|
|
40
|
-
_read_original_account,
|
|
41
|
-
_switch_gh_account,
|
|
42
|
-
_write_line,
|
|
43
|
-
)
|
|
44
|
-
from hooks_constants.gh_pr_author_swap_constants import ( # noqa: E402
|
|
45
|
-
REQUIRED_ACCOUNT_ENV_VAR,
|
|
46
|
-
STATE_FILE_PREFIX,
|
|
47
|
-
STATE_FILE_STALE_AGE_SECONDS,
|
|
48
|
-
STATE_FILE_SUFFIX,
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _collect_stale_state_files(temp_directory: Path) -> list[Path]:
|
|
53
|
-
"""Return swap-state files older than the stale threshold and safe to process.
|
|
54
|
-
|
|
55
|
-
A state file younger than ``STATE_FILE_STALE_AGE_SECONDS`` is
|
|
56
|
-
treated as belonging to a concurrent Claude Code session that may
|
|
57
|
-
still be mid-``gh pr create``. Sweeping such a file would steal the
|
|
58
|
-
active session's restore target. Files older than the threshold are
|
|
59
|
-
overwhelmingly likely to be stale — the enforcer-to-restore window
|
|
60
|
-
is bounded by the gh subprocess timeouts (10s switch + 5s api user
|
|
61
|
-
+ filesystem work), so any file older than
|
|
62
|
-
``STATE_FILE_STALE_AGE_SECONDS`` is past the longest plausible
|
|
63
|
-
active window.
|
|
64
|
-
|
|
65
|
-
Each candidate is also screened for ownership and permission bits
|
|
66
|
-
matching the enforcer's write contract. A file with mode bits other
|
|
67
|
-
than ``STATE_FILE_PERMISSION_MODE`` or (on POSIX) owned by a
|
|
68
|
-
different user is silently skipped — it was not written by an
|
|
69
|
-
enforcer running as the current user and must not be allowed to
|
|
70
|
-
drive ``gh auth switch``.
|
|
71
|
-
|
|
72
|
-
The candidate is inspected via ``lstat`` rather than ``stat`` so a
|
|
73
|
-
symlink at the predictable swap-state path is screened on its own
|
|
74
|
-
metadata, not on whatever the symlink resolves to. Any entry that
|
|
75
|
-
is not a regular file (symlink, socket, fifo, device) is silently
|
|
76
|
-
skipped. The enforcer creates state files with ``O_NOFOLLOW``;
|
|
77
|
-
mirroring that contract here closes the symlink-hijack window where
|
|
78
|
-
an attacker plants a symlink pointing to a legitimate 0o600 file
|
|
79
|
-
owned by the current user to trick the cleanup hook into reading
|
|
80
|
-
that file as a swap-state payload.
|
|
81
|
-
|
|
82
|
-
Returned paths are sorted by modification time in ascending order so
|
|
83
|
-
that when the caller iterates and runs ``gh auth switch`` for each
|
|
84
|
-
file, the newest stale file is processed LAST. ``gh auth switch``
|
|
85
|
-
is global state — only the last switch wins — so processing the
|
|
86
|
-
newest file last leaves the gh CLI on the most recently captured
|
|
87
|
-
original account when multiple sessions crashed with different
|
|
88
|
-
original accounts. The single ``lstat`` syscall performed per
|
|
89
|
-
candidate is reused for both the attacker-planted screen and the
|
|
90
|
-
mtime ordering key so the sort does not double-stat.
|
|
91
|
-
|
|
92
|
-
Args:
|
|
93
|
-
temp_directory: System temp directory returned by
|
|
94
|
-
``tempfile.gettempdir()``.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
List of swap-state file paths that are regular files whose
|
|
98
|
-
modification time is older than ``STATE_FILE_STALE_AGE_SECONDS``
|
|
99
|
-
seconds before now and whose ownership/mode bits match the
|
|
100
|
-
enforcer's write contract. Sorted by mtime ascending so the
|
|
101
|
-
newest stale file is last in iteration order. Empty list when
|
|
102
|
-
the temp directory cannot be listed.
|
|
103
|
-
"""
|
|
104
|
-
glob_pattern = f"{STATE_FILE_PREFIX}*{STATE_FILE_SUFFIX}"
|
|
105
|
-
current_time_seconds = time.time()
|
|
106
|
-
all_stale_candidates_with_mtime: list[tuple[float, Path]] = []
|
|
107
|
-
try:
|
|
108
|
-
all_candidate_paths = list(temp_directory.glob(glob_pattern))
|
|
109
|
-
except OSError:
|
|
110
|
-
return []
|
|
111
|
-
for each_candidate_path in all_candidate_paths:
|
|
112
|
-
try:
|
|
113
|
-
file_lstat_result = each_candidate_path.lstat()
|
|
114
|
-
except OSError:
|
|
115
|
-
continue
|
|
116
|
-
if _lstat_indicates_attacker_planted(file_lstat_result):
|
|
117
|
-
continue
|
|
118
|
-
file_age_seconds = current_time_seconds - file_lstat_result.st_mtime
|
|
119
|
-
if file_age_seconds >= STATE_FILE_STALE_AGE_SECONDS:
|
|
120
|
-
all_stale_candidates_with_mtime.append(
|
|
121
|
-
(file_lstat_result.st_mtime, each_candidate_path)
|
|
122
|
-
)
|
|
123
|
-
all_stale_candidates_with_mtime.sort(key=lambda each_mtime_path_pair: each_mtime_path_pair[0])
|
|
124
|
-
return [each_mtime_path_pair[1] for each_mtime_path_pair in all_stale_candidates_with_mtime]
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _restore_stale_state_file(state_file: Path) -> None:
|
|
128
|
-
"""Restore one stale state file: switch back, then delete on success.
|
|
129
|
-
|
|
130
|
-
A malformed state file is deleted without a switch attempt. A
|
|
131
|
-
well-formed file whose switch attempt fails is left on disk so the
|
|
132
|
-
next session-start can retry.
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
state_file: Absolute path to a candidate state file.
|
|
136
|
-
"""
|
|
137
|
-
original_account = _read_original_account(state_file)
|
|
138
|
-
if original_account is None:
|
|
139
|
-
_delete_state_file(state_file)
|
|
140
|
-
return
|
|
141
|
-
has_switched_account = _switch_gh_account(original_account)
|
|
142
|
-
if has_switched_account:
|
|
143
|
-
_delete_state_file(state_file)
|
|
144
|
-
else:
|
|
145
|
-
_write_line(
|
|
146
|
-
_build_restore_failure_message(
|
|
147
|
-
"gh-pr-author-cleanup",
|
|
148
|
-
original_account,
|
|
149
|
-
state_file,
|
|
150
|
-
"stale state file",
|
|
151
|
-
),
|
|
152
|
-
sys.stderr,
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def main() -> None:
|
|
157
|
-
"""Sweep stale gh-pr-author swap state files when the workflow is enabled.
|
|
158
|
-
|
|
159
|
-
Exits 0 in every path. When ``GITHUB_DEFAULT_ACCOUNT`` is unset the
|
|
160
|
-
hook returns immediately so users who have not opted into the swap
|
|
161
|
-
workflow see no behavior change. Otherwise iterates every matching
|
|
162
|
-
state file under ``tempfile.gettempdir()`` and restores each one
|
|
163
|
-
independently — a failure on one file does not block the others.
|
|
164
|
-
"""
|
|
165
|
-
required_account = os.environ.get(REQUIRED_ACCOUNT_ENV_VAR, "").strip()
|
|
166
|
-
if not required_account:
|
|
167
|
-
return
|
|
168
|
-
temp_directory = Path(tempfile.gettempdir())
|
|
169
|
-
all_stale_state_files = _collect_stale_state_files(temp_directory)
|
|
170
|
-
for each_state_file in all_stale_state_files:
|
|
171
|
-
_restore_stale_state_file(each_state_file)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if __name__ == "__main__":
|
|
175
|
-
main()
|