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,461 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook: block durable GitHub posts that reference volatile paths.
|
|
3
|
-
|
|
4
|
-
Root cause: a background job posted a PR comment citing an artifact under a job
|
|
5
|
-
scratch directory. Job tmp dirs, worktrees, and system temp locations are
|
|
6
|
-
ephemeral — they are cleaned soon after the run — while a posted comment, PR
|
|
7
|
-
body, issue, or review lives forever. A durable post that points at ephemeral
|
|
8
|
-
scratch breaks the moment that scratch is removed.
|
|
9
|
-
|
|
10
|
-
Detection strategy: gather the post body text from the tool call, then scan it
|
|
11
|
-
for volatile-path markers. Two tool families carry post bodies: shell ``gh``
|
|
12
|
-
post subcommands (``pr create/comment/edit/review``, ``issue
|
|
13
|
-
create/comment/edit``) and the GitHub MCP post tools (any
|
|
14
|
-
``mcp__plugin_github_github__*`` tool that carries a ``body`` or ``comment``
|
|
15
|
-
parameter). For a ``gh`` command the body comes from ``--body``/``-b`` inline
|
|
16
|
-
strings and from the file named by ``--body-file``/``-F`` (its contents are read
|
|
17
|
-
and scanned, since that content is what gets embedded in the post). The ``gh``
|
|
18
|
-
command is tokenized with ``shlex.split`` and the ``gh`` word must be the first
|
|
19
|
-
command token, so a ``gh pr comment`` that only appears as quoted data inside
|
|
20
|
-
another argument never classifies.
|
|
21
|
-
|
|
22
|
-
The marker scan runs in two tiers over the normalized body. Five bare markers
|
|
23
|
-
— the system temp locations and env tokens — match as a plain substring
|
|
24
|
-
anywhere. Two dot-relative directory markers, ``.claude-profile-a/jobs/`` and
|
|
25
|
-
``.claude/worktrees/``, match when either a slash sits immediately before the
|
|
26
|
-
marker or a path segment follows it. A machine-local absolute path
|
|
27
|
-
(``~/.claude/worktrees/wt/f.py``) and a relative path that names a child
|
|
28
|
-
(``see .claude/worktrees/wt-1/notes.md``) both count; a bare directory-name
|
|
29
|
-
mention with neither anchor posts cleanly.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
import json
|
|
33
|
-
import shlex
|
|
34
|
-
import sys
|
|
35
|
-
from pathlib import Path
|
|
36
|
-
|
|
37
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
38
|
-
if _hooks_dir not in sys.path:
|
|
39
|
-
sys.path.insert(0, _hooks_dir)
|
|
40
|
-
|
|
41
|
-
from blocking._gh_body_arg_utils import ( # noqa: E402
|
|
42
|
-
all_body_flags,
|
|
43
|
-
body_file_flag,
|
|
44
|
-
body_file_short_flag,
|
|
45
|
-
count_extra_tokens_to_skip_for_split_quoted_value,
|
|
46
|
-
get_logical_first_line,
|
|
47
|
-
is_unresolvable_shell_value,
|
|
48
|
-
match_body_file_equals_prefix,
|
|
49
|
-
match_body_flag_equals_prefix,
|
|
50
|
-
strip_surrounding_quotes,
|
|
51
|
-
)
|
|
52
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
53
|
-
from hooks_constants.volatile_path_in_post_blocker_constants import ( # noqa: E402
|
|
54
|
-
ALL_BARE_VOLATILE_PATH_MARKERS,
|
|
55
|
-
ALL_GH_POST_SUBCOMMANDS,
|
|
56
|
-
ALL_MCP_BODY_PARAM_NAMES,
|
|
57
|
-
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS,
|
|
58
|
-
BASH_TOOL_NAME,
|
|
59
|
-
BODY_FILE_ENCODING,
|
|
60
|
-
CORRECTIVE_MESSAGE,
|
|
61
|
-
GH_COMMAND_NAME,
|
|
62
|
-
MCP_GITHUB_TOOL_PREFIX,
|
|
63
|
-
MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT,
|
|
64
|
-
PATH_ANCHOR_CHARACTER,
|
|
65
|
-
PATH_SEGMENT_START_CHARACTERS,
|
|
66
|
-
TOKEN_JOIN_SEPARATOR,
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _character_starts_path_segment(character: str) -> bool:
|
|
71
|
-
"""Return whether a character can open a path segment after a marker."""
|
|
72
|
-
return bool(character) and (
|
|
73
|
-
character.isalnum() or character in PATH_SEGMENT_START_CHARACTERS
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _text_has_anchored_marker(normalized_text: str, marker: str) -> bool:
|
|
78
|
-
"""Return whether the marker appears as part of a path.
|
|
79
|
-
|
|
80
|
-
A match counts when either a slash sits immediately before the marker or a
|
|
81
|
-
path segment follows it. Every occurrence is scanned, so a bare directory
|
|
82
|
-
mention earlier in the text never masks a path occurrence later::
|
|
83
|
-
|
|
84
|
-
ok: ".claude/worktrees/ is the manifest key" -> False
|
|
85
|
-
flag: "path c:/users/me/.claude/worktrees/wt/f.py" -> True
|
|
86
|
-
flag: "see .claude/worktrees/wt-1/notes.md" -> True
|
|
87
|
-
|
|
88
|
-
Args:
|
|
89
|
-
normalized_text: The body text with backslashes folded to forward
|
|
90
|
-
slashes and lowercased.
|
|
91
|
-
marker: A dot-relative directory marker to search for.
|
|
92
|
-
|
|
93
|
-
Returns:
|
|
94
|
-
True when at least one occurrence is path-anchored.
|
|
95
|
-
"""
|
|
96
|
-
search_start = 0
|
|
97
|
-
while True:
|
|
98
|
-
found_index = normalized_text.find(marker, search_start)
|
|
99
|
-
if found_index < 0:
|
|
100
|
-
return False
|
|
101
|
-
preceding_character = normalized_text[found_index - 1 : found_index]
|
|
102
|
-
following_index = found_index + len(marker)
|
|
103
|
-
following_character = normalized_text[following_index : following_index + 1]
|
|
104
|
-
is_slash_before = preceding_character == PATH_ANCHOR_CHARACTER
|
|
105
|
-
is_segment_after = _character_starts_path_segment(following_character)
|
|
106
|
-
if is_slash_before or is_segment_after:
|
|
107
|
-
return True
|
|
108
|
-
search_start = found_index + 1
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def scan_text_for_volatile_marker(text: str) -> str | None:
|
|
112
|
-
"""Return the first volatile-path marker found in text, or None.
|
|
113
|
-
|
|
114
|
-
Backslashes normalize to forward slashes and the text lowercases before the
|
|
115
|
-
scan. The two dot-relative directory markers count when either a slash sits
|
|
116
|
-
immediately before them or a path segment follows them; the five bare
|
|
117
|
-
markers match anywhere::
|
|
118
|
-
|
|
119
|
-
ok: "the `.claude/worktrees/` manifest key" -> None
|
|
120
|
-
flag: r"C:\\Users\\me\\.claude-profile-a\\jobs\\x" -> ".claude-profile-a/jobs/"
|
|
121
|
-
flag: "see .claude/worktrees/wt-1/notes.md" -> ".claude/worktrees/"
|
|
122
|
-
flag: "saved to %TEMP%\\out.txt" -> "%temp%"
|
|
123
|
-
|
|
124
|
-
A dot-relative marker with neither a slash before it nor a path segment
|
|
125
|
-
after it — start of text naming the directory, a quoted config constant,
|
|
126
|
-
or a placeholder form such as ``.claude/worktrees/<name>`` — posts cleanly.
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
text: The post body text to scan.
|
|
130
|
-
|
|
131
|
-
Returns:
|
|
132
|
-
The matched marker string, or None when the text names no volatile path.
|
|
133
|
-
"""
|
|
134
|
-
normalized_text = text.replace("\\", PATH_ANCHOR_CHARACTER).lower()
|
|
135
|
-
for each_marker in ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS:
|
|
136
|
-
if _text_has_anchored_marker(normalized_text, each_marker):
|
|
137
|
-
return each_marker
|
|
138
|
-
for each_marker in ALL_BARE_VOLATILE_PATH_MARKERS:
|
|
139
|
-
if each_marker in normalized_text:
|
|
140
|
-
return each_marker
|
|
141
|
-
return None
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _is_environment_assignment(token: str) -> bool:
|
|
145
|
-
"""Return whether a token is a ``NAME=value`` shell environment assignment."""
|
|
146
|
-
equals_index = token.find("=")
|
|
147
|
-
if equals_index <= 0:
|
|
148
|
-
return False
|
|
149
|
-
name_part = token[:equals_index]
|
|
150
|
-
if not (name_part[0].isalpha() or name_part[0] == "_"):
|
|
151
|
-
return False
|
|
152
|
-
return all(
|
|
153
|
-
each_character.isalnum() or each_character == "_"
|
|
154
|
-
for each_character in name_part
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _tokens_name_gh_post_command(all_command_tokens: list[str]) -> bool:
|
|
159
|
-
"""Return whether the tokens start with an affected ``gh`` post subcommand.
|
|
160
|
-
|
|
161
|
-
Leading ``NAME=value`` env assignments are skipped, then the next token must
|
|
162
|
-
be the literal ``gh`` command word followed by a post noun and verb. Anchoring
|
|
163
|
-
to the first command word keeps a ``gh pr comment`` mentioned only as quoted
|
|
164
|
-
argument data from classifying.
|
|
165
|
-
|
|
166
|
-
Args:
|
|
167
|
-
all_command_tokens: The shlex-split tokens of the logical command line.
|
|
168
|
-
|
|
169
|
-
Returns:
|
|
170
|
-
True when the command invokes an affected ``gh`` post subcommand.
|
|
171
|
-
"""
|
|
172
|
-
command_index = 0
|
|
173
|
-
while command_index < len(all_command_tokens) and _is_environment_assignment(
|
|
174
|
-
all_command_tokens[command_index]
|
|
175
|
-
):
|
|
176
|
-
command_index += 1
|
|
177
|
-
if command_index >= len(all_command_tokens):
|
|
178
|
-
return False
|
|
179
|
-
if all_command_tokens[command_index] != GH_COMMAND_NAME:
|
|
180
|
-
return False
|
|
181
|
-
remaining_tokens = all_command_tokens[command_index + 1 :]
|
|
182
|
-
if len(remaining_tokens) < MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT:
|
|
183
|
-
return False
|
|
184
|
-
post_noun = remaining_tokens[0]
|
|
185
|
-
post_verb = remaining_tokens[1]
|
|
186
|
-
return post_verb in ALL_GH_POST_SUBCOMMANDS.get(post_noun, frozenset())
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _reassemble_split_quoted_value(
|
|
190
|
-
value_token: str, all_following_tokens: list[str]
|
|
191
|
-
) -> tuple[str, int]:
|
|
192
|
-
"""Rejoin a quoted value that ``shlex.split(posix=False)`` split on spaces.
|
|
193
|
-
|
|
194
|
-
``posix=False`` keeps backslashes intact (so Windows paths survive) but
|
|
195
|
-
splits a quoted value such as ``"dump in %TEMP%"`` into three tokens. This
|
|
196
|
-
rejoins the value token with the follow-on tokens up to the closing quote,
|
|
197
|
-
then strips the surrounding quotes so the full body text can be scanned.
|
|
198
|
-
|
|
199
|
-
Args:
|
|
200
|
-
value_token: The first token of the value, following the flag.
|
|
201
|
-
all_following_tokens: The tokens after ``value_token`` on the line.
|
|
202
|
-
|
|
203
|
-
Returns:
|
|
204
|
-
A ``(full_value, extra_tokens_consumed)`` pair; ``extra_tokens_consumed``
|
|
205
|
-
counts the follow-on tokens joined into the value.
|
|
206
|
-
"""
|
|
207
|
-
extra_tokens = count_extra_tokens_to_skip_for_split_quoted_value(
|
|
208
|
-
all_following_tokens, value_token
|
|
209
|
-
)
|
|
210
|
-
if not extra_tokens:
|
|
211
|
-
return strip_surrounding_quotes(value_token), 0
|
|
212
|
-
joined_value = value_token + TOKEN_JOIN_SEPARATOR + TOKEN_JOIN_SEPARATOR.join(
|
|
213
|
-
all_following_tokens[:extra_tokens]
|
|
214
|
-
)
|
|
215
|
-
return strip_surrounding_quotes(joined_value), extra_tokens
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _extract_flag_value_at(
|
|
219
|
-
all_command_tokens: list[str], token_index: int
|
|
220
|
-
) -> tuple[bool, str, int] | None:
|
|
221
|
-
"""Return a body/body-file value starting at token_index, or None.
|
|
222
|
-
|
|
223
|
-
Args:
|
|
224
|
-
all_command_tokens: The shlex-split tokens of the logical command line.
|
|
225
|
-
token_index: The index of the token to inspect.
|
|
226
|
-
|
|
227
|
-
Returns:
|
|
228
|
-
An ``(is_body_file, value, next_index)`` triple when the token opens a
|
|
229
|
-
body or body-file flag, else None. ``next_index`` is where scanning
|
|
230
|
-
resumes after the value's tokens.
|
|
231
|
-
"""
|
|
232
|
-
current_token = all_command_tokens[token_index]
|
|
233
|
-
following_tokens = all_command_tokens[token_index + 1:]
|
|
234
|
-
body_equals_prefix = match_body_flag_equals_prefix(current_token)
|
|
235
|
-
if body_equals_prefix is not None:
|
|
236
|
-
value, extra = _reassemble_split_quoted_value(
|
|
237
|
-
current_token[len(body_equals_prefix):], following_tokens
|
|
238
|
-
)
|
|
239
|
-
return False, value, token_index + 1 + extra
|
|
240
|
-
body_file_equals_prefix = match_body_file_equals_prefix(current_token)
|
|
241
|
-
if body_file_equals_prefix is not None:
|
|
242
|
-
value, extra = _reassemble_split_quoted_value(
|
|
243
|
-
current_token[len(body_file_equals_prefix):], following_tokens
|
|
244
|
-
)
|
|
245
|
-
return True, value, token_index + 1 + extra
|
|
246
|
-
if current_token in all_body_flags and following_tokens:
|
|
247
|
-
value, extra = _reassemble_split_quoted_value(following_tokens[0], following_tokens[1:])
|
|
248
|
-
return False, value, token_index + 2 + extra
|
|
249
|
-
if current_token in (body_file_flag, body_file_short_flag) and following_tokens:
|
|
250
|
-
value, extra = _reassemble_split_quoted_value(following_tokens[0], following_tokens[1:])
|
|
251
|
-
return True, value, token_index + 2 + extra
|
|
252
|
-
return None
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _collect_body_flag_values(
|
|
256
|
-
all_command_tokens: list[str],
|
|
257
|
-
) -> tuple[list[str], list[str]]:
|
|
258
|
-
"""Return inline body strings and body-file paths from the command tokens.
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
all_command_tokens: The shlex-split tokens of the logical command line.
|
|
262
|
-
|
|
263
|
-
Returns:
|
|
264
|
-
A ``(all_inline_bodies, all_body_file_paths)`` pair, each value stripped
|
|
265
|
-
of its surrounding quotes and rejoined across split-quote tokens.
|
|
266
|
-
"""
|
|
267
|
-
all_inline_bodies: list[str] = []
|
|
268
|
-
all_body_file_paths: list[str] = []
|
|
269
|
-
token_index = 0
|
|
270
|
-
while token_index < len(all_command_tokens):
|
|
271
|
-
extraction = _extract_flag_value_at(all_command_tokens, token_index)
|
|
272
|
-
if extraction is None:
|
|
273
|
-
token_index += 1
|
|
274
|
-
continue
|
|
275
|
-
is_body_file, value, next_index = extraction
|
|
276
|
-
if is_body_file:
|
|
277
|
-
all_body_file_paths.append(value)
|
|
278
|
-
else:
|
|
279
|
-
all_inline_bodies.append(value)
|
|
280
|
-
token_index = next_index
|
|
281
|
-
return all_inline_bodies, all_body_file_paths
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def _read_body_file(
|
|
285
|
-
body_file_path: str, working_directory: str | None = None
|
|
286
|
-
) -> str | None:
|
|
287
|
-
"""Return the contents of a body-file path, or None when it cannot be read.
|
|
288
|
-
|
|
289
|
-
Args:
|
|
290
|
-
body_file_path: The path given to ``--body-file``/``-F``.
|
|
291
|
-
working_directory: Optional base directory for relative body-file paths.
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
The file text, or None for an unresolvable shell value (such as ``-`` for
|
|
295
|
-
stdin or a ``$VAR`` path) or an unreadable file.
|
|
296
|
-
"""
|
|
297
|
-
if is_unresolvable_shell_value(body_file_path):
|
|
298
|
-
return None
|
|
299
|
-
resolved_path = Path(body_file_path)
|
|
300
|
-
if not resolved_path.is_absolute() and working_directory:
|
|
301
|
-
resolved_path = Path(working_directory) / body_file_path
|
|
302
|
-
try:
|
|
303
|
-
return resolved_path.read_text(encoding=BODY_FILE_ENCODING)
|
|
304
|
-
except OSError:
|
|
305
|
-
return None
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def extract_gh_post_body_texts(
|
|
309
|
-
command: str, working_directory: str | None = None
|
|
310
|
-
) -> list[str]:
|
|
311
|
-
"""Return every post body text an affected ``gh`` command would send.
|
|
312
|
-
|
|
313
|
-
Non-post ``gh`` commands and unparseable command lines yield an empty list.
|
|
314
|
-
Body-file contents are read from disk so the embedded text is scanned rather
|
|
315
|
-
than the file path. Relative body-file paths resolve against
|
|
316
|
-
*working_directory* when provided.
|
|
317
|
-
|
|
318
|
-
Args:
|
|
319
|
-
command: The raw Bash tool command string.
|
|
320
|
-
working_directory: Optional base directory for relative body-file paths.
|
|
321
|
-
|
|
322
|
-
Returns:
|
|
323
|
-
Inline ``--body`` strings plus the contents of each readable body-file.
|
|
324
|
-
"""
|
|
325
|
-
logical_line = get_logical_first_line(command)
|
|
326
|
-
if not logical_line:
|
|
327
|
-
return []
|
|
328
|
-
try:
|
|
329
|
-
all_command_tokens = shlex.split(logical_line, posix=False)
|
|
330
|
-
except ValueError:
|
|
331
|
-
return []
|
|
332
|
-
if not _tokens_name_gh_post_command(all_command_tokens):
|
|
333
|
-
return []
|
|
334
|
-
all_inline_bodies, all_body_file_paths = _collect_body_flag_values(
|
|
335
|
-
all_command_tokens
|
|
336
|
-
)
|
|
337
|
-
all_body_texts = list(all_inline_bodies)
|
|
338
|
-
for each_path in all_body_file_paths:
|
|
339
|
-
file_text = _read_body_file(each_path, working_directory=working_directory)
|
|
340
|
-
if file_text is not None:
|
|
341
|
-
all_body_texts.append(file_text)
|
|
342
|
-
return all_body_texts
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def extract_gh_post_body_texts_for_privacy_gate(
|
|
346
|
-
command: str, working_directory: str | None = None
|
|
347
|
-
) -> tuple[list[str], str | None]:
|
|
348
|
-
"""Like ``extract_gh_post_body_texts``, but fail-closed on unreadable body-files.
|
|
349
|
-
|
|
350
|
-
Args:
|
|
351
|
-
command: The raw Bash tool command string.
|
|
352
|
-
working_directory: Optional base directory for relative body-file paths.
|
|
353
|
-
|
|
354
|
-
Returns:
|
|
355
|
-
``(all_body_texts, None)`` when every declared body-file was read (or
|
|
356
|
-
none were declared), else ``([], deny_reason)`` when a body-file flag is
|
|
357
|
-
present but its contents could not be loaded for scanning.
|
|
358
|
-
"""
|
|
359
|
-
logical_line = get_logical_first_line(command)
|
|
360
|
-
if not logical_line:
|
|
361
|
-
return [], None
|
|
362
|
-
try:
|
|
363
|
-
all_command_tokens = shlex.split(logical_line, posix=False)
|
|
364
|
-
except ValueError:
|
|
365
|
-
return [], None
|
|
366
|
-
if not _tokens_name_gh_post_command(all_command_tokens):
|
|
367
|
-
return [], None
|
|
368
|
-
all_inline_bodies, all_body_file_paths = _collect_body_flag_values(
|
|
369
|
-
all_command_tokens
|
|
370
|
-
)
|
|
371
|
-
all_body_texts = list(all_inline_bodies)
|
|
372
|
-
for each_path in all_body_file_paths:
|
|
373
|
-
file_text = _read_body_file(each_path, working_directory=working_directory)
|
|
374
|
-
if file_text is None:
|
|
375
|
-
return [], (
|
|
376
|
-
"BLOCKED [pii_prevention_blocker]: durable post uses --body-file "
|
|
377
|
-
f"but '{each_path}' could not be read for PII scanning. Use an "
|
|
378
|
-
"absolute path, ensure the file exists, or pass --body text."
|
|
379
|
-
)
|
|
380
|
-
all_body_texts.append(file_text)
|
|
381
|
-
return all_body_texts, None
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def extract_mcp_body_texts(all_tool_input: dict[str, object]) -> list[str]:
|
|
385
|
-
"""Return the body and comment strings from a GitHub MCP post tool input.
|
|
386
|
-
|
|
387
|
-
Args:
|
|
388
|
-
all_tool_input: The MCP tool's input mapping.
|
|
389
|
-
|
|
390
|
-
Returns:
|
|
391
|
-
The non-empty string values of the ``body`` and ``comment`` parameters.
|
|
392
|
-
"""
|
|
393
|
-
all_body_texts: list[str] = []
|
|
394
|
-
for each_param_name in ALL_MCP_BODY_PARAM_NAMES:
|
|
395
|
-
param_value = all_tool_input.get(each_param_name)
|
|
396
|
-
if isinstance(param_value, str) and param_value:
|
|
397
|
-
all_body_texts.append(param_value)
|
|
398
|
-
return all_body_texts
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def _collect_body_texts(tool_name: str, all_tool_input: dict[str, object]) -> list[str]:
|
|
402
|
-
if tool_name == BASH_TOOL_NAME:
|
|
403
|
-
command = all_tool_input.get("command", "")
|
|
404
|
-
if not isinstance(command, str) or not command:
|
|
405
|
-
return []
|
|
406
|
-
return extract_gh_post_body_texts(command)
|
|
407
|
-
if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
|
|
408
|
-
return extract_mcp_body_texts(all_tool_input)
|
|
409
|
-
return []
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def _first_volatile_marker(all_body_texts: list[str]) -> str | None:
|
|
413
|
-
for each_text in all_body_texts:
|
|
414
|
-
matched_marker = scan_text_for_volatile_marker(each_text)
|
|
415
|
-
if matched_marker is not None:
|
|
416
|
-
return matched_marker
|
|
417
|
-
return None
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def _emit_block(tool_name: str, matched_marker: str) -> None:
|
|
421
|
-
deny_payload = {
|
|
422
|
-
"hookSpecificOutput": {
|
|
423
|
-
"hookEventName": "PreToolUse",
|
|
424
|
-
"permissionDecision": "deny",
|
|
425
|
-
"permissionDecisionReason": CORRECTIVE_MESSAGE,
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
log_hook_block(
|
|
429
|
-
calling_hook_name="volatile_path_in_post_blocker.py",
|
|
430
|
-
hook_event="PreToolUse",
|
|
431
|
-
block_reason=CORRECTIVE_MESSAGE,
|
|
432
|
-
tool_name=tool_name,
|
|
433
|
-
offending_input_preview=matched_marker,
|
|
434
|
-
)
|
|
435
|
-
print(json.dumps(deny_payload))
|
|
436
|
-
sys.stdout.flush()
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def main() -> None:
|
|
440
|
-
"""Read the PreToolUse payload and block a post that names a volatile path."""
|
|
441
|
-
try:
|
|
442
|
-
hook_input = json.load(sys.stdin)
|
|
443
|
-
except json.JSONDecodeError:
|
|
444
|
-
sys.exit(0)
|
|
445
|
-
|
|
446
|
-
tool_name = hook_input.get("tool_name", "")
|
|
447
|
-
tool_input = hook_input.get("tool_input", {})
|
|
448
|
-
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
449
|
-
sys.exit(0)
|
|
450
|
-
|
|
451
|
-
all_body_texts = _collect_body_texts(tool_name, tool_input)
|
|
452
|
-
matched_marker = _first_volatile_marker(all_body_texts)
|
|
453
|
-
if matched_marker is None:
|
|
454
|
-
sys.exit(0)
|
|
455
|
-
|
|
456
|
-
_emit_block(tool_name, matched_marker)
|
|
457
|
-
sys.exit(0)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if __name__ == "__main__":
|
|
461
|
-
main()
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
"""Configuration constants for the conventional_pr_title_gate PreToolUse hook."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
ALL_GH_EXECUTABLE_BASENAMES: frozenset[str] = frozenset({"gh", "gh.exe"})
|
|
6
|
-
PR_SUBCOMMAND_TOKEN: str = "pr"
|
|
7
|
-
ALL_PR_TITLE_SUBCOMMAND_VERBS: frozenset[str] = frozenset({"create", "edit"})
|
|
8
|
-
GH_PR_SUBCOMMAND_MINIMUM_TOKEN_COUNT: int = 3
|
|
9
|
-
|
|
10
|
-
BASH_TOOL_NAME: str = "Bash"
|
|
11
|
-
|
|
12
|
-
TITLE_LONG_FLAG: str = "--title"
|
|
13
|
-
TITLE_SHORT_FLAG: str = "-t"
|
|
14
|
-
REPO_LONG_FLAG: str = "--repo"
|
|
15
|
-
REPO_SHORT_FLAG: str = "-R"
|
|
16
|
-
|
|
17
|
-
WORKFLOWS_DIRECTORY_RELATIVE_PATH: str = ".github/workflows"
|
|
18
|
-
ALL_WORKFLOW_FILE_GLOB_PATTERNS: tuple[str, ...] = ("*.yml", "*.yaml")
|
|
19
|
-
|
|
20
|
-
ALL_SEMANTIC_TITLE_CI_MARKERS: tuple[str, ...] = (
|
|
21
|
-
"semantic-pull-request",
|
|
22
|
-
"action-semantic-pull-request",
|
|
23
|
-
"semantic_pull_request",
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
ALL_CONVENTIONAL_COMMIT_TYPES: tuple[str, ...] = (
|
|
27
|
-
"feat",
|
|
28
|
-
"fix",
|
|
29
|
-
"chore",
|
|
30
|
-
"docs",
|
|
31
|
-
"refactor",
|
|
32
|
-
"perf",
|
|
33
|
-
"ci",
|
|
34
|
-
"style",
|
|
35
|
-
"test",
|
|
36
|
-
"build",
|
|
37
|
-
"revert",
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
CONVENTIONAL_COMMIT_TITLE_PATTERN: re.Pattern[str] = re.compile(
|
|
41
|
-
r"^(?:" + "|".join(ALL_CONVENTIONAL_COMMIT_TYPES) + r")(?:\([^)]+\))?!?: .+"
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
SEMANTIC_ACTION_TYPES_INPUT_PATTERN: re.Pattern[str] = re.compile(r"^\s*types\s*:")
|
|
45
|
-
|
|
46
|
-
SEMANTIC_ACTION_FLOW_TYPES_INPUT_PATTERN: re.Pattern[str] = re.compile(r"[{,]\s*types\s*:")
|
|
47
|
-
|
|
48
|
-
YAML_LIST_ITEM_PREFIX: str = "- "
|
|
49
|
-
|
|
50
|
-
CORRECTIVE_MESSAGE: str = (
|
|
51
|
-
"BLOCKED [conventional-pr-title]: this repository's CI validates PR titles "
|
|
52
|
-
"against Conventional Commits, and the --title value here does not match. "
|
|
53
|
-
"Required shape: type(scope)!: description, where the scope and the "
|
|
54
|
-
"breaking-change marker (!) are optional. Allowed types: "
|
|
55
|
-
f"{', '.join(ALL_CONVENTIONAL_COMMIT_TYPES)}.\n\n"
|
|
56
|
-
"Example: feat(hooks): add the conventional PR title gate\n\n"
|
|
57
|
-
"Fix the --title value and retry."
|
|
58
|
-
)
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
"""Configuration constants for the gh-pr-author swap hook trio.
|
|
2
|
-
|
|
3
|
-
The PreToolUse enforcer (``gh_pr_author_enforcer.py``) auto-switches the
|
|
4
|
-
active ``gh`` CLI account to ``GITHUB_DEFAULT_ACCOUNT`` before a
|
|
5
|
-
``gh pr create`` invocation, the PostToolUse companion
|
|
6
|
-
(``gh_pr_author_restore.py``) restores the prior account afterwards, and
|
|
7
|
-
the SessionStart cleanup hook (``gh_pr_author_session_cleanup.py``)
|
|
8
|
-
sweeps any stale state files left behind when a prior session was
|
|
9
|
-
interrupted between the swap and the restore. The state file written
|
|
10
|
-
between the hooks is keyed per session so parallel Claude Code sessions
|
|
11
|
-
cannot stomp on each other's swap state.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import re
|
|
17
|
-
|
|
18
|
-
REQUIRED_ACCOUNT_ENV_VAR: str = "GITHUB_DEFAULT_ACCOUNT"
|
|
19
|
-
|
|
20
|
-
BASH_TOOL_NAME: str = "Bash"
|
|
21
|
-
|
|
22
|
-
GH_PR_CREATE_PATTERN: re.Pattern[str] = re.compile(
|
|
23
|
-
r"(?:^|[;&|\n`({]|\$\()[ \t]*"
|
|
24
|
-
r"(?:(?:if|then|else|elif|while|until|do|!)[ \t]+)*"
|
|
25
|
-
r"(?:[A-Za-z_][A-Za-z0-9_]*=\S+[ \t]+)*"
|
|
26
|
-
r"gh(?:[ \t]+(?:--[A-Za-z][\w-]*(?:=\S+)?|-[A-Za-z])(?:[ \t]+(?!-)\S+)?)*"
|
|
27
|
-
r"[ \t]+pr[ \t]+create\b",
|
|
28
|
-
re.IGNORECASE,
|
|
29
|
-
)
|
|
30
|
-
WEB_FLAG_PATTERN: re.Pattern[str] = re.compile(r"(?<!\S)(?:--web|-w)(?!\S)")
|
|
31
|
-
COMMAND_SEPARATOR_PATTERN: re.Pattern[str] = re.compile(
|
|
32
|
-
r"(?:&&|\|\||;|(?<!\|)\|(?!\|)|(?<!&)&(?!&)|[\r\n])"
|
|
33
|
-
)
|
|
34
|
-
BASH_COMMENT_INTRODUCER_CHARACTER: str = "#"
|
|
35
|
-
COMMAND_SUBSTITUTION_OPENER_LENGTH: int = 2
|
|
36
|
-
|
|
37
|
-
ALL_GH_API_USER_COMMAND: tuple[str, ...] = ("gh", "api", "user", "--jq", ".login")
|
|
38
|
-
GH_API_USER_TIMEOUT_SECONDS: int = 5
|
|
39
|
-
|
|
40
|
-
ALL_GH_AUTH_SWITCH_COMMAND_HEAD: tuple[str, ...] = ("gh", "auth", "switch", "--user")
|
|
41
|
-
GH_AUTH_SWITCH_TIMEOUT_SECONDS: int = 10
|
|
42
|
-
|
|
43
|
-
STATE_FILE_PREFIX: str = "gh_pr_author_swap_"
|
|
44
|
-
STATE_FILE_SUFFIX: str = ".json"
|
|
45
|
-
STATE_FILE_DEFAULT_SESSION_ID: str = "default"
|
|
46
|
-
|
|
47
|
-
SESSION_ID_UNSAFE_CHARACTERS_PATTERN: re.Pattern[str] = re.compile(r"[^A-Za-z0-9_-]")
|
|
48
|
-
|
|
49
|
-
STATE_FILE_ORIGINAL_ACCOUNT_KEY: str = "original_account"
|
|
50
|
-
STATE_FILE_PRIMARY_ACCOUNT_KEY: str = "primary_account"
|
|
51
|
-
|
|
52
|
-
STATE_FILE_PERMISSION_MODE: int = 0o600
|
|
53
|
-
|
|
54
|
-
STATE_FILE_PAYLOAD_TEXT_ENCODING_NAME: str = "utf-8"
|
|
55
|
-
|
|
56
|
-
OS_O_NOFOLLOW_ATTRIBUTE_NAME: str = "O_NOFOLLOW"
|
|
57
|
-
|
|
58
|
-
STATE_FILE_STALE_AGE_SECONDS: int = 1800
|
|
59
|
-
|
|
60
|
-
ALL_SHELL_QUOTE_CHARACTERS: tuple[str, ...] = ("\"", "'")
|
|
61
|
-
SHELL_QUOTE_REPLACEMENT_CHARACTER: str = " "
|
|
62
|
-
SHELL_BACKSLASH_ESCAPE_PAIR_LENGTH: int = 2
|
|
63
|
-
SHELL_BACKTICK_CHARACTER: str = "`"
|
|
64
|
-
SHELL_DOLLAR_CHARACTER: str = "$"
|
|
65
|
-
SHELL_PAREN_OPEN_CHARACTER: str = "("
|
|
66
|
-
SHELL_PAREN_CLOSE_CHARACTER: str = ")"
|
|
67
|
-
SHELL_LESS_THAN_CHARACTER: str = "<"
|
|
68
|
-
SHELL_BACKSLASH_CHARACTER: str = "\\"
|
|
69
|
-
SHELL_NEWLINE_CHARACTER: str = "\n"
|
|
70
|
-
|
|
71
|
-
HEREDOC_OPENER_TAG_PATTERN: re.Pattern[str] = re.compile(
|
|
72
|
-
r"[ \t]*(?P<dash>-?)[ \t]*(?:'(?P<sq_tag>[A-Za-z_][A-Za-z0-9_]*)'"
|
|
73
|
-
r"|\"(?P<dq_tag>[A-Za-z_][A-Za-z0-9_]*)\""
|
|
74
|
-
r"|(?P<bare_tag>[A-Za-z_][A-Za-z0-9_]*))"
|
|
75
|
-
)
|
|
76
|
-
HEREDOC_OPENER_TOKEN_LENGTH: int = 2
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"""Configuration constants for the es_exe_path_rewriter PreToolUse hook."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
BASH_TOOL_NAME = "Bash"
|
|
6
|
-
|
|
7
|
-
HOOK_EVENT_NAME = "PreToolUse"
|
|
8
|
-
|
|
9
|
-
PERMISSION_ALLOW = "allow"
|
|
10
|
-
|
|
11
|
-
PLACEHOLDER_TOKEN_PATTERN = re.compile(
|
|
12
|
-
r"""(?:(?<=\s)|^)['"]?(?<!\$)\{([^}]+)\}['"]?(?=\s|$)""",
|
|
13
|
-
)
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"""Constants for the pr-description-writer spawn tracker and its gh pr create gate.
|
|
2
|
-
|
|
3
|
-
The tracker records one fact per session: the session spawned the
|
|
4
|
-
``pr-description-writer`` agent. The gate reads that fact when the session runs
|
|
5
|
-
``gh pr create`` and denies the command when the fact is absent.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import tempfile
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
from hooks_constants.session_edit_stage_gate_constants import (
|
|
14
|
-
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
PR_DESCRIPTION_WRITER_SUBAGENT_TYPE: str = "pr-description-writer"
|
|
18
|
-
|
|
19
|
-
ALL_AGENT_SPAWN_TOOL_NAMES: frozenset[str] = frozenset({"Agent", "Task"})
|
|
20
|
-
|
|
21
|
-
SPAWN_MARKER_FILE_PREFIX: str = "claude-session-pr-description-writer-"
|
|
22
|
-
SPAWN_MARKER_FILE_SUFFIX: str = ".marker"
|
|
23
|
-
|
|
24
|
-
SUBAGENT_TYPE_FIELD_NAME: str = "subagent_type"
|
|
25
|
-
TOOL_INPUT_FIELD_NAME: str = "tool_input"
|
|
26
|
-
TOOL_NAME_FIELD_NAME: str = "tool_name"
|
|
27
|
-
COMMAND_FIELD_NAME: str = "command"
|
|
28
|
-
|
|
29
|
-
ALL_GH_EXECUTABLE_BASENAMES: frozenset[str] = frozenset({"gh", "gh.exe", "gh.cmd"})
|
|
30
|
-
PR_SUBCOMMAND_TOKEN: str = "pr"
|
|
31
|
-
CREATE_SUBCOMMAND_TOKEN: str = "create"
|
|
32
|
-
GH_PR_CREATE_MINIMUM_TOKEN_COUNT: int = 3
|
|
33
|
-
|
|
34
|
-
SPAWN_BYPASS_MARKER: str = "# pr-description-skip"
|
|
35
|
-
|
|
36
|
-
CORRECTIVE_MESSAGE: str = (
|
|
37
|
-
"BLOCKED: [PR_DESCRIPTION_WRITER] This session runs `gh pr create` with no "
|
|
38
|
-
"`pr-description-writer` agent spawn recorded.\n\n"
|
|
39
|
-
"The agent writes the title and body from the current diff, then returns the "
|
|
40
|
-
"markdown and a body-file path for you to publish.\n\n"
|
|
41
|
-
"Two ways forward:\n"
|
|
42
|
-
" 1. Spawn the agent (Agent tool, subagent_type `pr-description-writer`), "
|
|
43
|
-
"then run `gh pr create --body-file <path>` with what it returns.\n"
|
|
44
|
-
" 2. Append the trailing comment `# pr-description-skip` to the command when "
|
|
45
|
-
"you are writing the body yourself on purpose."
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def spawn_marker_path(session_id: str, temp_directory: Path | None = None) -> Path | None:
|
|
50
|
-
"""Return this session's marker file path, or None when it names no session.
|
|
51
|
-
|
|
52
|
-
The tracker writes this path and the gate reads it, so the file name lives
|
|
53
|
-
in one place. A session id that sanitizes to nothing yields no path, so
|
|
54
|
-
every marker on disk belongs to one real session and a marker a crashed
|
|
55
|
-
session leaves behind is inert.
|
|
56
|
-
|
|
57
|
-
Args:
|
|
58
|
-
session_id: Raw ``session_id`` from the hook payload.
|
|
59
|
-
temp_directory: The directory holding the marker. Defaults to the
|
|
60
|
-
system temp directory.
|
|
61
|
-
|
|
62
|
-
Returns:
|
|
63
|
-
Absolute path to this session's marker file, or None when the payload
|
|
64
|
-
names no session this hook can key on.
|
|
65
|
-
"""
|
|
66
|
-
sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
|
|
67
|
-
if not sanitized_session_id:
|
|
68
|
-
return None
|
|
69
|
-
file_name = (
|
|
70
|
-
f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
|
|
71
|
-
)
|
|
72
|
-
resolved_temp_directory = temp_directory or Path(tempfile.gettempdir())
|
|
73
|
-
return resolved_temp_directory / file_name
|