claude-dev-env 1.40.0 → 1.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.
Files changed (39) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
  3. package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
  4. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
  5. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  6. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  7. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  8. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  9. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  10. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  11. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  12. package/hooks/blocking/pr_description_enforcer.py +1 -3
  13. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  14. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  15. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  16. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  17. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  18. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  19. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  20. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  21. package/hooks/config/pr_description_enforcer_constants.py +5 -0
  22. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  23. package/hooks/hooks.json +40 -0
  24. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  25. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  26. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  27. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  28. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  29. package/package.json +1 -1
  30. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  31. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  32. package/skills/bugteam/reference/audit-contract.md +22 -0
  33. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  34. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  35. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  36. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  37. package/skills/pr-converge/SKILL.md +8 -2
  38. package/skills/pr-converge/config/constants.py +2 -1
  39. package/skills/pr-converge/reference/state-schema.md +36 -8
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: record formal bugteam Skill invocations into pr-converge state.
3
+
4
+ Companion to ``pr_converge_bugteam_enforcer``. On every
5
+ ``Skill({skill: "bugteam"})`` invocation, this hook stamps
6
+ ``$CLAUDE_JOB_DIR/pr-converge-state.json`` with
7
+ ``bugteam_skill_invoked_at_head = current_head`` and
8
+ ``bugteam_skill_invoked_at_tick = tick_count`` so the enforcer can confirm
9
+ the formal Skill fired this tick at the current HEAD before allowing any
10
+ follow-on clean-coder audit-shaped Agent spawn.
11
+
12
+ ``qbug`` invocations are deliberately ignored — qbug is not an accepted
13
+ substitute for the formal bugteam Skill at Step 5.
14
+
15
+ The hook never blocks: it returns exit 0 in every branch so the Skill call
16
+ proceeds unchanged.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import TextIO
27
+
28
+
29
+ def _insert_hooks_tree_for_imports() -> None:
30
+ hooks_tree = Path(__file__).resolve().parent.parent
31
+ hooks_tree_string = str(hooks_tree)
32
+ if hooks_tree_string not in sys.path:
33
+ sys.path.insert(0, hooks_tree_string)
34
+
35
+
36
+ _insert_hooks_tree_for_imports()
37
+
38
+ from config.pr_converge_bugteam_enforcer_constants import (
39
+ BUGTEAM_SKILL_NAME,
40
+ SKILL_TOOL_NAME,
41
+ STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD,
42
+ STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK,
43
+ STATE_FIELD_CURRENT_HEAD,
44
+ STATE_FIELD_TICK_COUNT,
45
+ STATE_FILE_ATOMIC_WRITE_SUFFIX,
46
+ STATE_FILE_JSON_INDENT_SPACES,
47
+ )
48
+ from config.pr_converge_bugteam_enforcer_state import (
49
+ load_state_dictionary,
50
+ resolve_state_path,
51
+ )
52
+
53
+
54
+ def _atomic_write_state(state_path: Path, state_by_field: dict[str, object]) -> None:
55
+ """Serialize state to disk atomically via tempfile + rename.
56
+
57
+ Args:
58
+ state_path: Destination ``pr-converge-state.json`` path.
59
+ state_by_field: Updated state mapping each field name to its value.
60
+ """
61
+ parent_directory = state_path.parent
62
+ parent_directory.mkdir(parents=True, exist_ok=True)
63
+ encoded_text = json.dumps(state_by_field, indent=STATE_FILE_JSON_INDENT_SPACES, sort_keys=True)
64
+ with tempfile.NamedTemporaryFile(
65
+ mode="w",
66
+ encoding="utf-8",
67
+ dir=str(parent_directory),
68
+ delete=False,
69
+ suffix=STATE_FILE_ATOMIC_WRITE_SUFFIX,
70
+ ) as temporary_handle:
71
+ temporary_handle.write(encoded_text)
72
+ temporary_path = Path(temporary_handle.name)
73
+ try:
74
+ os.replace(str(temporary_path), str(state_path))
75
+ except OSError:
76
+ Path(temporary_path).unlink(missing_ok=True)
77
+ raise
78
+
79
+
80
+ def _emit_missing_state_warning(output_stream: TextIO) -> None:
81
+ """Write the missing-state warning to the provided stream.
82
+
83
+ Args:
84
+ output_stream: Writable text stream — production code passes
85
+ ``sys.stderr``; tests pass a ``StringIO`` to capture the message.
86
+ """
87
+ output_stream.write(
88
+ "pr_converge_bugteam_skill_tracker: state file lacks current_head or "
89
+ "tick_count; bugteam invocation NOT recorded\n"
90
+ )
91
+ output_stream.flush()
92
+
93
+
94
+ def _record_bugteam_skill_invocation(
95
+ state_by_field: dict[str, object],
96
+ ) -> dict[str, object] | None:
97
+ """Return a copy of state with bugteam-Skill invocation fields stamped, or None on no-op.
98
+
99
+ The two stamp fields are owned exclusively by this tracker. Concurrent
100
+ writes from the orchestrator never touch them, so the read-modify-write
101
+ window cannot lose an orchestrator update on these specific keys.
102
+
103
+ When ``current_head`` (str) or ``tick_count`` (int) is missing or wrong-typed,
104
+ the function emits a stderr warning via ``_emit_missing_state_warning`` and
105
+ returns ``None`` so the caller skips the disk write entirely. Skipping the
106
+ no-op write narrows the read-modify-write window against the orchestrator —
107
+ concurrent updates to non-stamp fields (``phase``, ``tick_count``, etc.)
108
+ cannot be silently lost by a tracker rewrite that changes nothing.
109
+
110
+ Args:
111
+ state_by_field: Existing pr-converge state mapping each field name to
112
+ its value.
113
+
114
+ Returns:
115
+ New dictionary identical to ``state_by_field`` plus
116
+ ``bugteam_skill_invoked_at_head`` set to ``current_head`` and
117
+ ``bugteam_skill_invoked_at_tick`` set to ``tick_count`` when both
118
+ source fields are present and well-typed; ``None`` when either source
119
+ field is missing or wrong-typed, signaling the caller to skip the
120
+ atomic write.
121
+ """
122
+ current_head = state_by_field.get(STATE_FIELD_CURRENT_HEAD)
123
+ current_tick = state_by_field.get(STATE_FIELD_TICK_COUNT)
124
+ if not isinstance(current_head, str) or not isinstance(current_tick, int):
125
+ _emit_missing_state_warning(sys.stderr)
126
+ return None
127
+ updated_state: dict[str, object] = dict(state_by_field)
128
+ updated_state[STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD] = current_head
129
+ updated_state[STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK] = current_tick
130
+ return updated_state
131
+
132
+
133
+ def _is_formal_bugteam_skill_invocation(payload_by_field: dict[str, object]) -> bool:
134
+ """Return True when this hook invocation matches the formal bugteam Skill.
135
+
136
+ Args:
137
+ payload_by_field: The full PreToolUse hook payload (already JSON-parsed),
138
+ keyed by top-level field name.
139
+
140
+ Returns:
141
+ True when ``tool_name == "Skill"`` and ``tool_input["skill"]
142
+ == "bugteam"``. Returns False for qbug and every other skill.
143
+ """
144
+ if payload_by_field.get("tool_name", "") != SKILL_TOOL_NAME:
145
+ return False
146
+ tool_input = payload_by_field.get("tool_input", {})
147
+ if not isinstance(tool_input, dict):
148
+ return False
149
+ return tool_input.get("skill", "") == BUGTEAM_SKILL_NAME
150
+
151
+
152
+ def _emit_state_write_error(state_write_error: OSError, output_stream: TextIO) -> None:
153
+ """Write the state-write failure message to the provided stream.
154
+
155
+ Args:
156
+ state_write_error: The OSError raised by ``_atomic_write_state``.
157
+ output_stream: Writable text stream — production code passes
158
+ ``sys.stderr``; tests pass a ``StringIO`` to capture the message.
159
+ """
160
+ output_stream.write(
161
+ f"pr_converge_bugteam_skill_tracker: state write failed; "
162
+ f"stamp not recorded: {state_write_error}\n"
163
+ )
164
+ output_stream.flush()
165
+
166
+
167
+ def main() -> None:
168
+ """Tracker entry point for the PreToolUse:Skill hook.
169
+
170
+ Reads the PreToolUse payload from stdin, records a formal
171
+ ``Skill({skill: "bugteam"})`` invocation in the pr-converge state file,
172
+ and always returns 0 — including on a state-write failure, since a
173
+ non-zero PreToolUse exit would block the very Skill invocation this
174
+ hook exists to record. State-write failures are surfaced via
175
+ ``_emit_state_write_error`` to stderr so the operator still sees the
176
+ protocol-corruption signal.
177
+ """
178
+ try:
179
+ hook_payload = json.load(sys.stdin)
180
+ except json.JSONDecodeError:
181
+ return
182
+ if not isinstance(hook_payload, dict):
183
+ return
184
+ if not _is_formal_bugteam_skill_invocation(hook_payload):
185
+ return
186
+ state_path = resolve_state_path()
187
+ if state_path is None:
188
+ return
189
+ parsed_state = load_state_dictionary(state_path)
190
+ if parsed_state is None:
191
+ return
192
+ updated_state = _record_bugteam_skill_invocation(parsed_state)
193
+ if updated_state is None:
194
+ return
195
+ try:
196
+ _atomic_write_state(state_path, updated_state)
197
+ except OSError as state_write_error:
198
+ _emit_state_write_error(state_write_error, sys.stderr)
199
+ return
200
+ return
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
@@ -0,0 +1,283 @@
1
+ """Unit tests for the pr_converge_bugteam_skill_tracker PreToolUse hook.
2
+
3
+ Covers the bugteam-only update path: only Skill({skill: "bugteam"}) updates
4
+ the invocation fields in $CLAUDE_JOB_DIR/pr-converge-state.json (the file
5
+ named by PR_CONVERGE_STATE_FILENAME). qbug, other skills, and missing-state
6
+ cases all return exit 0 without modifying state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import io
13
+ import json
14
+ import pathlib
15
+ import sys
16
+ from typing import Any
17
+ from unittest import mock
18
+
19
+ import pytest
20
+
21
+ _HOOK_DIR = pathlib.Path(__file__).parent
22
+ _HOOKS_TREE = _HOOK_DIR.parent
23
+ for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
24
+ if each_path not in sys.path:
25
+ sys.path.insert(0, each_path)
26
+
27
+ hook_spec = importlib.util.spec_from_file_location(
28
+ "pr_converge_bugteam_skill_tracker",
29
+ _HOOK_DIR / "pr_converge_bugteam_skill_tracker.py",
30
+ )
31
+ assert hook_spec is not None
32
+ assert hook_spec.loader is not None
33
+ hook_module = importlib.util.module_from_spec(hook_spec)
34
+ hook_spec.loader.exec_module(hook_module)
35
+
36
+ from config.pr_converge_bugteam_enforcer_constants import (
37
+ CLAUDE_JOB_DIR_ENV_VAR,
38
+ PR_CONVERGE_STATE_FILENAME,
39
+ )
40
+
41
+ _HEAD_SHA = "abc123def456abc123def456abc123def456abcd"
42
+ _TICK_COUNT = 4
43
+
44
+
45
+ def _write_state(state_directory: pathlib.Path, state: dict[str, Any]) -> pathlib.Path:
46
+ state_path = state_directory / PR_CONVERGE_STATE_FILENAME
47
+ state_path.write_text(json.dumps(state), encoding="utf-8")
48
+ return state_path
49
+
50
+
51
+ def _baseline_state() -> dict[str, Any]:
52
+ return {
53
+ "phase": "BUGTEAM",
54
+ "current_head": _HEAD_SHA,
55
+ "tick_count": _TICK_COUNT,
56
+ "bugteam_skill_invoked_at_head": None,
57
+ "bugteam_skill_invoked_at_tick": None,
58
+ }
59
+
60
+
61
+ def _read_state(state_directory: pathlib.Path) -> dict[str, Any]:
62
+ state_path = state_directory / PR_CONVERGE_STATE_FILENAME
63
+ return json.loads(state_path.read_text(encoding="utf-8"))
64
+
65
+
66
+ def _run_main_with_io(input_text: str) -> None:
67
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
68
+ with mock.patch("sys.stdout", new_callable=io.StringIO):
69
+ try:
70
+ hook_module.main()
71
+ except SystemExit:
72
+ pass
73
+
74
+
75
+ def _run_main_capturing_stderr(input_text: str) -> str:
76
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
77
+ with mock.patch("sys.stdout", new_callable=io.StringIO):
78
+ with mock.patch("sys.stderr", new_callable=io.StringIO) as captured_stderr:
79
+ try:
80
+ hook_module.main()
81
+ except SystemExit:
82
+ pass
83
+ return captured_stderr.getvalue()
84
+
85
+
86
+ @pytest.fixture()
87
+ def claude_job_directory(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
88
+ monkeypatch.setenv(CLAUDE_JOB_DIR_ENV_VAR, str(tmp_path))
89
+ return tmp_path
90
+
91
+
92
+ def test_should_record_invocation_when_bugteam_skill_fires(
93
+ claude_job_directory: pathlib.Path,
94
+ ) -> None:
95
+ _write_state(claude_job_directory, _baseline_state())
96
+ skill_payload: dict[str, Any] = {
97
+ "tool_name": "Skill",
98
+ "tool_input": {"skill": "bugteam", "args": "https://github.com/o/r/pull/1"},
99
+ }
100
+ _run_main_with_io(json.dumps(skill_payload))
101
+ updated_state = _read_state(claude_job_directory)
102
+ assert updated_state["bugteam_skill_invoked_at_head"] == _HEAD_SHA
103
+ assert updated_state["bugteam_skill_invoked_at_tick"] == _TICK_COUNT
104
+
105
+
106
+ def test_should_not_record_invocation_when_qbug_skill_fires(
107
+ claude_job_directory: pathlib.Path,
108
+ ) -> None:
109
+ _write_state(claude_job_directory, _baseline_state())
110
+ qbug_payload: dict[str, Any] = {
111
+ "tool_name": "Skill",
112
+ "tool_input": {"skill": "qbug"},
113
+ }
114
+ _run_main_with_io(json.dumps(qbug_payload))
115
+ updated_state = _read_state(claude_job_directory)
116
+ assert updated_state["bugteam_skill_invoked_at_head"] is None
117
+ assert updated_state["bugteam_skill_invoked_at_tick"] is None
118
+
119
+
120
+ def test_should_ignore_unrelated_skills(
121
+ claude_job_directory: pathlib.Path,
122
+ ) -> None:
123
+ _write_state(claude_job_directory, _baseline_state())
124
+ other_payload: dict[str, Any] = {
125
+ "tool_name": "Skill",
126
+ "tool_input": {"skill": "agent-prompt"},
127
+ }
128
+ _run_main_with_io(json.dumps(other_payload))
129
+ updated_state = _read_state(claude_job_directory)
130
+ assert updated_state["bugteam_skill_invoked_at_head"] is None
131
+
132
+
133
+ def test_should_ignore_non_skill_tools(
134
+ claude_job_directory: pathlib.Path,
135
+ ) -> None:
136
+ _write_state(claude_job_directory, _baseline_state())
137
+ agent_payload: dict[str, Any] = {
138
+ "tool_name": "Agent",
139
+ "tool_input": {"subagent_type": "clean-coder", "prompt": "fix this"},
140
+ }
141
+ _run_main_with_io(json.dumps(agent_payload))
142
+ updated_state = _read_state(claude_job_directory)
143
+ assert updated_state["bugteam_skill_invoked_at_head"] is None
144
+
145
+
146
+ def test_should_no_op_when_state_file_absent(
147
+ claude_job_directory: pathlib.Path,
148
+ ) -> None:
149
+ skill_payload: dict[str, Any] = {
150
+ "tool_name": "Skill",
151
+ "tool_input": {"skill": "bugteam"},
152
+ }
153
+ _run_main_with_io(json.dumps(skill_payload))
154
+ assert not (claude_job_directory / PR_CONVERGE_STATE_FILENAME).exists()
155
+
156
+
157
+ def test_should_no_op_when_state_json_is_malformed(
158
+ claude_job_directory: pathlib.Path,
159
+ ) -> None:
160
+ state_path = claude_job_directory / PR_CONVERGE_STATE_FILENAME
161
+ state_path.write_text("{not valid json", encoding="utf-8")
162
+ skill_payload: dict[str, Any] = {
163
+ "tool_name": "Skill",
164
+ "tool_input": {"skill": "bugteam"},
165
+ }
166
+ _run_main_with_io(json.dumps(skill_payload))
167
+ assert state_path.read_text(encoding="utf-8") == "{not valid json"
168
+
169
+
170
+ def test_should_preserve_other_state_fields_on_update(
171
+ claude_job_directory: pathlib.Path,
172
+ ) -> None:
173
+ baseline_state = _baseline_state()
174
+ baseline_state["bugbot_clean_at"] = _HEAD_SHA
175
+ baseline_state["copilot_wait_count"] = 2
176
+ _write_state(claude_job_directory, baseline_state)
177
+ skill_payload: dict[str, Any] = {
178
+ "tool_name": "Skill",
179
+ "tool_input": {"skill": "bugteam"},
180
+ }
181
+ _run_main_with_io(json.dumps(skill_payload))
182
+ updated_state = _read_state(claude_job_directory)
183
+ assert updated_state["bugbot_clean_at"] == _HEAD_SHA
184
+ assert updated_state["copilot_wait_count"] == 2
185
+ assert updated_state["bugteam_skill_invoked_at_head"] == _HEAD_SHA
186
+
187
+
188
+ def test_should_no_op_when_claude_job_dir_env_var_absent(
189
+ monkeypatch: pytest.MonkeyPatch,
190
+ ) -> None:
191
+ monkeypatch.delenv(CLAUDE_JOB_DIR_ENV_VAR, raising=False)
192
+ skill_payload: dict[str, Any] = {
193
+ "tool_name": "Skill",
194
+ "tool_input": {"skill": "bugteam"},
195
+ }
196
+ _run_main_with_io(json.dumps(skill_payload))
197
+
198
+
199
+ def test_should_no_op_when_payload_is_malformed_json(
200
+ claude_job_directory: pathlib.Path,
201
+ ) -> None:
202
+ baseline_state = _baseline_state()
203
+ _write_state(claude_job_directory, baseline_state)
204
+ _run_main_with_io("not valid json {{{")
205
+ updated_state = _read_state(claude_job_directory)
206
+ assert updated_state["bugteam_skill_invoked_at_head"] is None
207
+
208
+
209
+ def test_should_leave_state_unchanged_when_current_head_is_missing(
210
+ claude_job_directory: pathlib.Path,
211
+ ) -> None:
212
+ state_without_head = _baseline_state()
213
+ del state_without_head["current_head"]
214
+ _write_state(claude_job_directory, state_without_head)
215
+ skill_payload: dict[str, Any] = {
216
+ "tool_name": "Skill",
217
+ "tool_input": {"skill": "bugteam"},
218
+ }
219
+ captured_stderr = _run_main_capturing_stderr(json.dumps(skill_payload))
220
+ updated_state = _read_state(claude_job_directory)
221
+ assert "current_head" not in updated_state
222
+ assert updated_state.get("bugteam_skill_invoked_at_head") is None
223
+ assert updated_state.get("bugteam_skill_invoked_at_tick") is None
224
+ assert "current_head or tick_count" in captured_stderr
225
+
226
+
227
+ def test_should_leave_state_unchanged_when_tick_count_is_missing(
228
+ claude_job_directory: pathlib.Path,
229
+ ) -> None:
230
+ state_without_tick = _baseline_state()
231
+ del state_without_tick["tick_count"]
232
+ _write_state(claude_job_directory, state_without_tick)
233
+ skill_payload: dict[str, Any] = {
234
+ "tool_name": "Skill",
235
+ "tool_input": {"skill": "bugteam"},
236
+ }
237
+ captured_stderr = _run_main_capturing_stderr(json.dumps(skill_payload))
238
+ updated_state = _read_state(claude_job_directory)
239
+ assert "tick_count" not in updated_state
240
+ assert updated_state.get("bugteam_skill_invoked_at_head") is None
241
+ assert updated_state.get("bugteam_skill_invoked_at_tick") is None
242
+ assert "current_head or tick_count" in captured_stderr
243
+
244
+
245
+ def test_should_preserve_prior_stamp_when_current_head_becomes_missing(
246
+ claude_job_directory: pathlib.Path,
247
+ ) -> None:
248
+ prior_head = "feedface0000feedface0000feedface0000feed"
249
+ prior_tick = 3
250
+ state_with_prior_stamp = _baseline_state()
251
+ state_with_prior_stamp["bugteam_skill_invoked_at_head"] = prior_head
252
+ state_with_prior_stamp["bugteam_skill_invoked_at_tick"] = prior_tick
253
+ del state_with_prior_stamp["current_head"]
254
+ _write_state(claude_job_directory, state_with_prior_stamp)
255
+ skill_payload: dict[str, Any] = {
256
+ "tool_name": "Skill",
257
+ "tool_input": {"skill": "bugteam"},
258
+ }
259
+ _run_main_capturing_stderr(json.dumps(skill_payload))
260
+ updated_state = _read_state(claude_job_directory)
261
+ assert updated_state["bugteam_skill_invoked_at_head"] == prior_head
262
+ assert updated_state["bugteam_skill_invoked_at_tick"] == prior_tick
263
+
264
+
265
+ def test_should_skip_atomic_write_when_state_missing_required_fields(
266
+ claude_job_directory: pathlib.Path,
267
+ ) -> None:
268
+ state_without_head = _baseline_state()
269
+ del state_without_head["current_head"]
270
+ _write_state(claude_job_directory, state_without_head)
271
+ skill_payload: dict[str, Any] = {
272
+ "tool_name": "Skill",
273
+ "tool_input": {"skill": "bugteam"},
274
+ }
275
+ with mock.patch.object(hook_module, "_atomic_write_state") as patched_atomic_write:
276
+ with mock.patch("sys.stdin", io.StringIO(json.dumps(skill_payload))):
277
+ with mock.patch("sys.stdout", new_callable=io.StringIO):
278
+ with mock.patch("sys.stderr", new_callable=io.StringIO):
279
+ try:
280
+ hook_module.main()
281
+ except SystemExit:
282
+ pass
283
+ assert patched_atomic_write.call_count == 0
@@ -0,0 +1,171 @@
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
+
33
+ _hooks_tree_path = str(Path(__file__).absolute().parent.parent)
34
+ if _hooks_tree_path not in sys.path:
35
+ sys.path.insert(0, _hooks_tree_path)
36
+
37
+ from _gh_pr_author_swap_utils import ( # noqa: E402 # sys.path shim above must run first
38
+ _delete_state_file,
39
+ _lstat_indicates_attacker_planted,
40
+ _read_original_account,
41
+ _switch_gh_account,
42
+ _write_line,
43
+ )
44
+ from config.gh_pr_author_swap_constants import ( # noqa: E402 # sys.path shim above must run first
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
+ f"[gh-pr-author-cleanup] failed to restore active gh account to {original_account!r} from "
147
+ f"stale state file {state_file}; left in place for next session",
148
+ sys.stderr,
149
+ )
150
+
151
+
152
+ def main() -> None:
153
+ """Sweep stale gh-pr-author swap state files when the workflow is enabled.
154
+
155
+ Exits 0 in every path. When ``GITHUB_DEFAULT_ACCOUNT`` is unset the
156
+ hook returns immediately so users who have not opted into the swap
157
+ workflow see no behavior change. Otherwise iterates every matching
158
+ state file under ``tempfile.gettempdir()`` and restores each one
159
+ independently — a failure on one file does not block the others.
160
+ """
161
+ required_account = os.environ.get(REQUIRED_ACCOUNT_ENV_VAR, "").strip()
162
+ if not required_account:
163
+ return
164
+ temp_directory = Path(tempfile.gettempdir())
165
+ all_stale_state_files = _collect_stale_state_files(temp_directory)
166
+ for each_state_file in all_stale_state_files:
167
+ _restore_stale_state_file(each_state_file)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()