ai-push-hooks 0.1.19 → 0.2.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/CHANGELOG.md +63 -0
- package/README.md +296 -62
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +25 -4
- package/pyproject.toml +11 -4
- package/src/ai_push_hooks/artifacts.py +57 -9
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +281 -15
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +845 -43
- package/src/ai_push_hooks/executors/exec.py +734 -114
- package/src/ai_push_hooks/executors/llm.py +369 -42
- package/src/ai_push_hooks/hook.py +129 -22
- package/src/ai_push_hooks/install.py +205 -0
- package/src/ai_push_hooks/modules/beads.py +21 -6
- package/src/ai_push_hooks/modules/docs.py +147 -27
- package/src/ai_push_hooks/modules/pr.py +53 -7
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +6 -2
- package/src/ai_push_hooks/types.py +82 -3
|
@@ -1,24 +1,50 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
3
5
|
import pathlib
|
|
4
6
|
import re
|
|
5
7
|
import shutil
|
|
8
|
+
import stat
|
|
6
9
|
from pathlib import PurePosixPath
|
|
7
10
|
from typing import Any
|
|
8
11
|
|
|
9
12
|
from ..types import CollectorResult, RuntimeContext
|
|
10
|
-
from ..executors.exec import collect_commit_messages_for_ranges, git, run_command
|
|
13
|
+
from ..executors.exec import collect_commit_messages_for_ranges, git, path_matches, run_command
|
|
11
14
|
|
|
12
15
|
DOC_INCLUDE_PATTERNS = ("README.md", "docs/**/*.md")
|
|
13
16
|
DOC_IGNORE_PATTERNS = ("docs/archive/**",)
|
|
17
|
+
DOC_CONTEXT_LINES = 2
|
|
18
|
+
DOC_MAX_BYTES = 64 * 1024
|
|
19
|
+
DOC_CONTEXT_BUDGET = 32000
|
|
20
|
+
DOC_FALLBACK_FILE_LIMIT = 8
|
|
14
21
|
|
|
15
22
|
|
|
16
23
|
def _path_matches(path: str, patterns: tuple[str, ...]) -> bool:
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
return any(path_matches(path, pattern) for pattern in patterns)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_safe_doc_file(repo_root: pathlib.Path, candidate: pathlib.Path) -> bool:
|
|
28
|
+
"""Return whether candidate is a contained, non-link regular file."""
|
|
29
|
+
try:
|
|
30
|
+
relative = candidate.relative_to(repo_root)
|
|
31
|
+
current = repo_root
|
|
32
|
+
for part in relative.parts:
|
|
33
|
+
current /= part
|
|
34
|
+
if stat.S_ISLNK(current.lstat().st_mode):
|
|
35
|
+
return False
|
|
36
|
+
candidate_stat = candidate.lstat()
|
|
37
|
+
if not stat.S_ISREG(candidate_stat.st_mode):
|
|
38
|
+
return False
|
|
39
|
+
resolved = candidate.resolve(strict=True)
|
|
40
|
+
resolved.relative_to(repo_root)
|
|
41
|
+
except (OSError, RuntimeError, ValueError):
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
19
44
|
|
|
20
45
|
|
|
21
46
|
def _expand_doc_files(repo_root: pathlib.Path) -> list[pathlib.Path]:
|
|
47
|
+
repo_root = repo_root.resolve(strict=True)
|
|
22
48
|
files: list[pathlib.Path] = []
|
|
23
49
|
for candidate in repo_root.rglob("*.md"):
|
|
24
50
|
relative = candidate.relative_to(repo_root).as_posix()
|
|
@@ -26,6 +52,8 @@ def _expand_doc_files(repo_root: pathlib.Path) -> list[pathlib.Path]:
|
|
|
26
52
|
continue
|
|
27
53
|
if _path_matches(relative, DOC_IGNORE_PATTERNS):
|
|
28
54
|
continue
|
|
55
|
+
if not _is_safe_doc_file(repo_root, candidate):
|
|
56
|
+
continue
|
|
29
57
|
files.append(candidate)
|
|
30
58
|
return sorted(files)
|
|
31
59
|
|
|
@@ -74,50 +102,142 @@ def _deterministic_seed_queries(diff_text: str, changed_files: list[str]) -> lis
|
|
|
74
102
|
return deduped[:20]
|
|
75
103
|
|
|
76
104
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
105
|
+
def _read_bounded_text(path: pathlib.Path, max_bytes: int | None = None) -> str:
|
|
106
|
+
if max_bytes is None:
|
|
107
|
+
max_bytes = DOC_MAX_BYTES
|
|
108
|
+
flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
|
|
109
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
110
|
+
flags |= os.O_NOFOLLOW
|
|
111
|
+
try:
|
|
112
|
+
descriptor = os.open(path, flags)
|
|
113
|
+
except OSError:
|
|
114
|
+
return ""
|
|
115
|
+
try:
|
|
116
|
+
file_stat = os.fstat(descriptor)
|
|
117
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
118
|
+
return ""
|
|
119
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
120
|
+
descriptor = -1
|
|
121
|
+
return handle.read(max_bytes).decode("utf-8", errors="replace")
|
|
122
|
+
except (OSError, UnicodeError):
|
|
123
|
+
return ""
|
|
124
|
+
finally:
|
|
125
|
+
if descriptor != -1:
|
|
126
|
+
os.close(descriptor)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _append_context_chunk(chunks: list[str], chunk: str, budget: int) -> bool:
|
|
130
|
+
current_size = sum(len(item) for item in chunks) + max(0, len(chunks) - 1)
|
|
131
|
+
remaining = budget - current_size
|
|
132
|
+
if remaining <= 0:
|
|
133
|
+
return False
|
|
134
|
+
truncated = len(chunk) > remaining
|
|
135
|
+
if truncated:
|
|
136
|
+
if chunks:
|
|
137
|
+
return False
|
|
138
|
+
chunk = chunk[:remaining]
|
|
139
|
+
chunks.append(chunk)
|
|
140
|
+
return not truncated
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _fallback_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path]) -> str:
|
|
144
|
+
snippets: list[str] = []
|
|
145
|
+
for path in doc_files[:DOC_FALLBACK_FILE_LIMIT]:
|
|
146
|
+
relative = path.relative_to(repo_root).as_posix()
|
|
147
|
+
content = _read_bounded_text(path)
|
|
148
|
+
block = f"--- {relative} ---\n{content}"
|
|
149
|
+
current_size = len("\n\n".join(snippets))
|
|
150
|
+
remaining = DOC_CONTEXT_BUDGET - current_size
|
|
151
|
+
if len(block) > remaining:
|
|
152
|
+
if not snippets and remaining > 0:
|
|
153
|
+
snippets.append(block[:remaining])
|
|
154
|
+
break
|
|
155
|
+
snippets.append(block)
|
|
156
|
+
return "\n\n".join(snippets)
|
|
82
157
|
|
|
83
158
|
|
|
84
159
|
def _search_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path], queries: list[str]) -> str:
|
|
160
|
+
repo_root = repo_root.resolve(strict=True)
|
|
85
161
|
if not doc_files:
|
|
86
162
|
return ""
|
|
87
|
-
if
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
163
|
+
if not queries:
|
|
164
|
+
return _fallback_docs_context(repo_root, doc_files)
|
|
165
|
+
|
|
166
|
+
if shutil.which("rg") is None:
|
|
167
|
+
chunks: list[str] = []
|
|
168
|
+
seen: set[tuple[str, int]] = set()
|
|
169
|
+
for query in queries:
|
|
170
|
+
for path in doc_files:
|
|
171
|
+
relative = path.relative_to(repo_root).as_posix()
|
|
172
|
+
lines = _read_bounded_text(path).splitlines()
|
|
173
|
+
matching_lines = [index for index, line in enumerate(lines) if query in line]
|
|
174
|
+
for matching_index in matching_lines:
|
|
175
|
+
first = max(0, matching_index - DOC_CONTEXT_LINES)
|
|
176
|
+
last = min(len(lines), matching_index + DOC_CONTEXT_LINES + 1)
|
|
177
|
+
for index in range(first, last):
|
|
178
|
+
key = (relative, index + 1)
|
|
179
|
+
if key in seen:
|
|
180
|
+
continue
|
|
181
|
+
seen.add(key)
|
|
182
|
+
chunk = f"{relative}:{index + 1}: {lines[index]}"
|
|
183
|
+
if not _append_context_chunk(chunks, chunk, DOC_CONTEXT_BUDGET):
|
|
184
|
+
return "\n".join(chunks)
|
|
185
|
+
return "\n".join(chunks) if chunks else _fallback_docs_context(repo_root, doc_files)
|
|
186
|
+
|
|
187
|
+
files = [path.relative_to(repo_root).as_posix() for path in doc_files]
|
|
188
|
+
allowed_files = set(files)
|
|
100
189
|
chunks: list[str] = []
|
|
101
190
|
seen: set[tuple[str, int]] = set()
|
|
102
191
|
for query in queries:
|
|
103
192
|
completed = run_command(
|
|
104
|
-
[
|
|
193
|
+
[
|
|
194
|
+
"rg",
|
|
195
|
+
"--json",
|
|
196
|
+
"--fixed-strings",
|
|
197
|
+
"--with-filename",
|
|
198
|
+
"--color=never",
|
|
199
|
+
"--context",
|
|
200
|
+
str(DOC_CONTEXT_LINES),
|
|
201
|
+
"--max-filesize",
|
|
202
|
+
str(DOC_MAX_BYTES),
|
|
203
|
+
"--",
|
|
204
|
+
query,
|
|
205
|
+
*files,
|
|
206
|
+
],
|
|
105
207
|
cwd=repo_root,
|
|
106
208
|
check=False,
|
|
107
209
|
)
|
|
108
210
|
if completed.returncode not in {0, 1}:
|
|
109
211
|
continue
|
|
110
212
|
for line in completed.stdout.splitlines():
|
|
111
|
-
|
|
112
|
-
|
|
213
|
+
try:
|
|
214
|
+
message = json.loads(line)
|
|
215
|
+
except json.JSONDecodeError:
|
|
216
|
+
continue
|
|
217
|
+
if message.get("type") not in {"match", "context"}:
|
|
218
|
+
continue
|
|
219
|
+
data = message.get("data")
|
|
220
|
+
if not isinstance(data, dict):
|
|
221
|
+
continue
|
|
222
|
+
path_data = data.get("path")
|
|
223
|
+
lines_data = data.get("lines")
|
|
224
|
+
file_name = path_data.get("text") if isinstance(path_data, dict) else None
|
|
225
|
+
line_number = data.get("line_number")
|
|
226
|
+
content = lines_data.get("text") if isinstance(lines_data, dict) else None
|
|
227
|
+
if (
|
|
228
|
+
not isinstance(file_name, str)
|
|
229
|
+
or file_name not in allowed_files
|
|
230
|
+
or not isinstance(line_number, int)
|
|
231
|
+
or not isinstance(content, str)
|
|
232
|
+
):
|
|
113
233
|
continue
|
|
114
|
-
file_name, line_number, content = parsed
|
|
115
234
|
key = (file_name, line_number)
|
|
116
235
|
if key in seen:
|
|
117
236
|
continue
|
|
118
237
|
seen.add(key)
|
|
119
|
-
|
|
120
|
-
|
|
238
|
+
clean_content = content.rstrip("\r\n")
|
|
239
|
+
chunk = f"{file_name}:{line_number}: {clean_content}"
|
|
240
|
+
if not _append_context_chunk(chunks, chunk, DOC_CONTEXT_BUDGET):
|
|
121
241
|
return "\n".join(chunks)
|
|
122
242
|
return "\n".join(chunks)
|
|
123
243
|
|
|
@@ -4,17 +4,32 @@ from typing import Any
|
|
|
4
4
|
|
|
5
5
|
from ..executors.exec import (
|
|
6
6
|
collect_commit_messages_for_ranges,
|
|
7
|
-
current_branch,
|
|
8
7
|
env_bool,
|
|
8
|
+
initial_pr_defer_reason,
|
|
9
9
|
is_feature_branch,
|
|
10
10
|
lookup_open_pr_url,
|
|
11
|
+
resolve_github_repository,
|
|
11
12
|
)
|
|
12
13
|
from ..types import CollectorResult, RuntimeContext
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
16
|
-
branch_name =
|
|
17
|
+
branch_name = str(context.cache.get("branch_name", ""))
|
|
18
|
+
branch_selection_reason = str(
|
|
19
|
+
context.cache.get("branch_selection_reason", "no pushed branch updates")
|
|
20
|
+
)
|
|
17
21
|
base_branch = context.config.general.base_branch.strip() or "main"
|
|
22
|
+
if not branch_name:
|
|
23
|
+
return CollectorResult(
|
|
24
|
+
artifacts={
|
|
25
|
+
"pr-context.txt": (
|
|
26
|
+
f"branch=\nbase_branch={base_branch}\n"
|
|
27
|
+
f"branch_selection_reason={branch_selection_reason}\n"
|
|
28
|
+
)
|
|
29
|
+
},
|
|
30
|
+
skip_module=True,
|
|
31
|
+
skip_reason=branch_selection_reason,
|
|
32
|
+
)
|
|
18
33
|
flag_env = ""
|
|
19
34
|
for step in state.module.steps:
|
|
20
35
|
if step.when_env:
|
|
@@ -26,15 +41,42 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
26
41
|
skip_module=True,
|
|
27
42
|
skip_reason="PR create env flag is not enabled",
|
|
28
43
|
)
|
|
29
|
-
if
|
|
44
|
+
if branch_name in {"HEAD", base_branch} or not is_feature_branch(branch_name):
|
|
30
45
|
return CollectorResult(
|
|
31
46
|
artifacts={"pr-context.txt": f"branch={branch_name}\n"},
|
|
32
47
|
skip_module=True,
|
|
33
48
|
skip_reason="branch does not require PR creation",
|
|
34
49
|
)
|
|
50
|
+
initial_push = bool(context.cache.get("branch_is_new", False))
|
|
51
|
+
if initial_push:
|
|
52
|
+
reason = initial_pr_defer_reason(branch_name, base_branch)
|
|
53
|
+
context.logger.warn("pr.create_deferred", reason, branch=branch_name)
|
|
54
|
+
return CollectorResult(
|
|
55
|
+
artifacts={
|
|
56
|
+
"pr-context.txt": (
|
|
57
|
+
f"branch={branch_name}\nbase_branch={base_branch}\n"
|
|
58
|
+
"initial_push=true\n"
|
|
59
|
+
f"defer_reason={reason}\n"
|
|
60
|
+
),
|
|
61
|
+
"deferred-result.json": {
|
|
62
|
+
"skipped": True,
|
|
63
|
+
"pr_url": "",
|
|
64
|
+
"deferred_until_remote": True,
|
|
65
|
+
"reason": reason,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
skip_module=True,
|
|
69
|
+
skip_reason=reason,
|
|
70
|
+
metadata={"deferred_until_remote": True, "reason": reason},
|
|
71
|
+
)
|
|
72
|
+
repository = resolve_github_repository(
|
|
73
|
+
context.repo_root, context.remote_name, context.remote_url
|
|
74
|
+
)
|
|
35
75
|
existing_pr_url = ""
|
|
36
76
|
try:
|
|
37
|
-
existing_pr_url = lookup_open_pr_url(
|
|
77
|
+
existing_pr_url = lookup_open_pr_url(
|
|
78
|
+
context.repo_root, branch_name, base_branch, repository
|
|
79
|
+
)
|
|
38
80
|
except Exception: # noqa: BLE001
|
|
39
81
|
existing_pr_url = ""
|
|
40
82
|
if existing_pr_url:
|
|
@@ -45,9 +87,11 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
45
87
|
metadata={"existing_pr_url": existing_pr_url},
|
|
46
88
|
)
|
|
47
89
|
|
|
48
|
-
ranges = context.cache.get("ranges", [])
|
|
49
|
-
changed_files = context.cache.get(
|
|
50
|
-
|
|
90
|
+
ranges = context.cache.get("branch_ranges", context.cache.get("ranges", []))
|
|
91
|
+
changed_files = context.cache.get(
|
|
92
|
+
"branch_changed_files", context.cache.get("changed_files", [])
|
|
93
|
+
)
|
|
94
|
+
diff_text = context.cache.get("branch_diff_text", context.cache.get("diff_text", ""))
|
|
51
95
|
commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
52
96
|
commit_lines = []
|
|
53
97
|
for commit in commits:
|
|
@@ -64,6 +108,8 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
64
108
|
f"branch={branch_name}",
|
|
65
109
|
f"base_branch={base_branch}",
|
|
66
110
|
f"remote_name={context.remote_name or 'origin'}",
|
|
111
|
+
f"repository={repository}",
|
|
112
|
+
f"initial_push={'true' if initial_push else 'false'}",
|
|
67
113
|
]
|
|
68
114
|
)
|
|
69
115
|
+ "\n",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import stat
|
|
6
|
+
import tempfile
|
|
7
|
+
import unicodedata
|
|
8
|
+
|
|
9
|
+
from .types import HookError
|
|
10
|
+
|
|
11
|
+
PRIVATE_FILE_MODE = 0o600
|
|
12
|
+
PRIVATE_DIRECTORY_MODE = 0o700
|
|
13
|
+
ORDINARY_FILE_MODE_MASK = 0o777
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def relative_path_parts(raw: str, label: str) -> tuple[str, ...]:
|
|
17
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
18
|
+
raise HookError(f"{label} must be a non-empty relative path")
|
|
19
|
+
if "\x00" in raw or any(ord(character) < 32 for character in raw):
|
|
20
|
+
raise HookError(f"{label} contains invalid control characters")
|
|
21
|
+
|
|
22
|
+
normalized = raw.replace("\\", "/")
|
|
23
|
+
windows_path = pathlib.PureWindowsPath(raw)
|
|
24
|
+
if normalized.startswith("/") or windows_path.is_absolute() or windows_path.drive:
|
|
25
|
+
raise HookError(f"{label} must be relative: {raw}")
|
|
26
|
+
|
|
27
|
+
raw_parts = normalized.split("/")
|
|
28
|
+
if any(part == ".." for part in raw_parts):
|
|
29
|
+
raise HookError(f"{label} must not contain '..': {raw}")
|
|
30
|
+
parts = tuple(part for part in raw_parts if part not in {"", "."})
|
|
31
|
+
if not parts:
|
|
32
|
+
raise HookError(f"{label} must be a non-empty relative path")
|
|
33
|
+
return parts
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_path_component(raw: str, label: str) -> str:
|
|
37
|
+
parts = relative_path_parts(raw, label)
|
|
38
|
+
if len(parts) != 1 or "/" in raw or "\\" in raw or parts[0] in {".", ".."}:
|
|
39
|
+
raise HookError(f"{label} must be a single path component: {raw}")
|
|
40
|
+
return parts[0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_path_within(path: pathlib.Path, root: pathlib.Path) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
path.relative_to(root)
|
|
46
|
+
return True
|
|
47
|
+
except ValueError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalized_component(value: str) -> str:
|
|
52
|
+
return unicodedata.normalize("NFKC", value).casefold()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sanitize_file_mode(mode: int) -> int:
|
|
56
|
+
return mode & ORDINARY_FILE_MODE_MASK
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def path_is_link_or_reparse(path: pathlib.Path) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
metadata = path.lstat()
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return False
|
|
64
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
65
|
+
return stat.S_ISLNK(metadata.st_mode) or bool(
|
|
66
|
+
getattr(metadata, "st_file_attributes", 0) & reparse_flag
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def path_has_symlink(root: pathlib.Path, path: pathlib.Path) -> bool:
|
|
71
|
+
lexical_root = pathlib.Path(os.path.abspath(root))
|
|
72
|
+
lexical_path = pathlib.Path(os.path.abspath(path))
|
|
73
|
+
try:
|
|
74
|
+
relative = lexical_path.relative_to(lexical_root)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return True
|
|
77
|
+
current = lexical_root
|
|
78
|
+
if path_is_link_or_reparse(current):
|
|
79
|
+
return True
|
|
80
|
+
for part in relative.parts:
|
|
81
|
+
current = current / part
|
|
82
|
+
if path_is_link_or_reparse(current):
|
|
83
|
+
return True
|
|
84
|
+
if not current.exists():
|
|
85
|
+
break
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_contained_path(base: pathlib.Path, raw: str, label: str) -> pathlib.Path:
|
|
90
|
+
parts = relative_path_parts(raw, label)
|
|
91
|
+
lexical_base = pathlib.Path(os.path.abspath(base))
|
|
92
|
+
lexical_candidate = lexical_base.joinpath(*parts)
|
|
93
|
+
if not is_path_within(lexical_candidate, lexical_base):
|
|
94
|
+
raise HookError(f"{label} escapes its intended directory: {raw}")
|
|
95
|
+
if path_has_symlink(lexical_base, lexical_candidate):
|
|
96
|
+
raise HookError(f"{label} traverses a symlink or reparse point: {raw}")
|
|
97
|
+
|
|
98
|
+
resolved_base = lexical_base.resolve(strict=False)
|
|
99
|
+
resolved_candidate = lexical_candidate.resolve(strict=False)
|
|
100
|
+
if not is_path_within(resolved_candidate, resolved_base):
|
|
101
|
+
raise HookError(f"{label} escapes its intended directory through a symlink: {raw}")
|
|
102
|
+
return resolved_candidate
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ensure_private_directory(
|
|
106
|
+
path: pathlib.Path,
|
|
107
|
+
*,
|
|
108
|
+
private_root: pathlib.Path | None = None,
|
|
109
|
+
) -> pathlib.Path:
|
|
110
|
+
target = pathlib.Path(os.path.abspath(path))
|
|
111
|
+
if private_root is None:
|
|
112
|
+
missing: list[pathlib.Path] = []
|
|
113
|
+
current = target
|
|
114
|
+
while not current.exists() and not path_is_link_or_reparse(current):
|
|
115
|
+
missing.append(current)
|
|
116
|
+
current = current.parent
|
|
117
|
+
if path_is_link_or_reparse(current) or not current.is_dir():
|
|
118
|
+
raise HookError(f"Private runtime directory has an unsafe parent: {path}")
|
|
119
|
+
for directory in reversed(missing):
|
|
120
|
+
directory.mkdir(mode=PRIVATE_DIRECTORY_MODE)
|
|
121
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
122
|
+
if path_is_link_or_reparse(target) or not target.is_dir():
|
|
123
|
+
raise HookError(f"Private runtime directory is unsafe: {path}")
|
|
124
|
+
os.chmod(target, PRIVATE_DIRECTORY_MODE)
|
|
125
|
+
return target
|
|
126
|
+
|
|
127
|
+
root = pathlib.Path(os.path.abspath(private_root))
|
|
128
|
+
if not is_path_within(target, root):
|
|
129
|
+
raise HookError(f"Private runtime directory escapes its namespace: {path}")
|
|
130
|
+
if path_is_link_or_reparse(root.parent) or not root.parent.is_dir():
|
|
131
|
+
raise HookError(f"Private runtime namespace has an unsafe parent: {root}")
|
|
132
|
+
directories = [root]
|
|
133
|
+
current = root
|
|
134
|
+
for part in target.relative_to(root).parts:
|
|
135
|
+
current = current / part
|
|
136
|
+
directories.append(current)
|
|
137
|
+
for directory in directories:
|
|
138
|
+
if path_is_link_or_reparse(directory):
|
|
139
|
+
raise HookError(
|
|
140
|
+
f"Private runtime directory traverses a symlink or reparse point: {directory}"
|
|
141
|
+
)
|
|
142
|
+
if not directory.exists():
|
|
143
|
+
directory.mkdir(mode=PRIVATE_DIRECTORY_MODE)
|
|
144
|
+
if not directory.is_dir():
|
|
145
|
+
raise HookError(f"Private runtime path is not a directory: {directory}")
|
|
146
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
147
|
+
return target
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def atomic_write_bytes(
|
|
151
|
+
path: pathlib.Path,
|
|
152
|
+
content: bytes,
|
|
153
|
+
*,
|
|
154
|
+
mode: int = PRIVATE_FILE_MODE,
|
|
155
|
+
) -> None:
|
|
156
|
+
parent = path.parent.resolve(strict=True)
|
|
157
|
+
target = parent / path.name
|
|
158
|
+
if path_is_link_or_reparse(target):
|
|
159
|
+
raise HookError(f"Refusing to replace symlink or reparse point: {target}")
|
|
160
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=".ai-push-hooks-", dir=parent)
|
|
161
|
+
temporary_path = pathlib.Path(temporary_name)
|
|
162
|
+
try:
|
|
163
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
164
|
+
descriptor = -1
|
|
165
|
+
handle.write(content)
|
|
166
|
+
handle.flush()
|
|
167
|
+
os.fsync(handle.fileno())
|
|
168
|
+
os.chmod(temporary_path, sanitize_file_mode(mode))
|
|
169
|
+
if path_is_link_or_reparse(target):
|
|
170
|
+
raise HookError(f"Refusing to replace symlink or reparse point: {target}")
|
|
171
|
+
os.replace(temporary_path, target)
|
|
172
|
+
finally:
|
|
173
|
+
if descriptor >= 0:
|
|
174
|
+
os.close(descriptor)
|
|
175
|
+
try:
|
|
176
|
+
temporary_path.unlink()
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def write_text_no_follow(path: pathlib.Path, content: str, *, encoding: str = "utf-8") -> None:
|
|
182
|
+
atomic_write_bytes(path, content.encode(encoding))
|
|
@@ -32,10 +32,14 @@ Rules:
|
|
|
32
32
|
BEADS_PLAN_PROMPT = """Check the attached branch context and output a JSON object describing Beads alignment work.
|
|
33
33
|
|
|
34
34
|
Return keys:
|
|
35
|
-
- commands: array
|
|
35
|
+
- commands: array containing only these exact non-interactive command forms:
|
|
36
|
+
- bd update <issue-id> [<issue-id> ...] --status <open|in_progress|blocked>
|
|
37
|
+
- bd close <issue-id> [<issue-id> ...] [--reason <text>]
|
|
36
38
|
- unresolved: boolean
|
|
37
39
|
- report_markdown: markdown string or empty
|
|
38
40
|
|
|
41
|
+
Do not return global flags or any other bd subcommands.
|
|
42
|
+
|
|
39
43
|
Return JSON only.
|
|
40
44
|
"""
|
|
41
45
|
|
|
@@ -68,7 +72,7 @@ base_branch = "main"
|
|
|
68
72
|
|
|
69
73
|
[llm]
|
|
70
74
|
runner = "opencode"
|
|
71
|
-
model = "openai/gpt-5.
|
|
75
|
+
model = "openai/gpt-5.6-terra"
|
|
72
76
|
variant = ""
|
|
73
77
|
timeout_seconds = 800
|
|
74
78
|
max_parallel = 2
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import pathlib
|
|
6
|
+
import stat
|
|
5
7
|
import sys
|
|
6
8
|
from dataclasses import dataclass, field
|
|
7
9
|
from datetime import datetime, timezone
|
|
@@ -11,12 +13,54 @@ READ_ONLY_STEP_TYPES = frozenset({"collect", "llm"})
|
|
|
11
13
|
PROMPTABLE_STEP_TYPES = frozenset({"llm", "apply"})
|
|
12
14
|
SUPPORTED_STEP_TYPES = frozenset({"collect", "llm", "apply", "exec", "assert"})
|
|
13
15
|
FEATURE_BRANCH_PREFIXES = ("feat/", "feature/")
|
|
16
|
+
ZERO_OID_LENGTHS = frozenset({40, 64})
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
class HookError(RuntimeError):
|
|
17
20
|
pass
|
|
18
21
|
|
|
19
22
|
|
|
23
|
+
def is_zero_oid(value: str) -> bool:
|
|
24
|
+
return len(value) in ZERO_OID_LENGTHS and not value.strip("0")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class PushRefUpdate:
|
|
29
|
+
local_ref: str
|
|
30
|
+
local_sha: str
|
|
31
|
+
remote_ref: str
|
|
32
|
+
remote_sha: str
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ref_kind(self) -> str:
|
|
36
|
+
if self.remote_ref.startswith("refs/heads/"):
|
|
37
|
+
return "branch"
|
|
38
|
+
if self.remote_ref.startswith("refs/tags/"):
|
|
39
|
+
return "tag"
|
|
40
|
+
return "other"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def operation(self) -> str:
|
|
44
|
+
if is_zero_oid(self.local_sha):
|
|
45
|
+
return "delete"
|
|
46
|
+
if is_zero_oid(self.remote_sha):
|
|
47
|
+
return "create"
|
|
48
|
+
return "update"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def branch_name(self) -> str | None:
|
|
52
|
+
if self.ref_kind != "branch" or self.operation == "delete":
|
|
53
|
+
return None
|
|
54
|
+
return self.remote_ref.removeprefix("refs/heads/")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PushRevisionRange:
|
|
59
|
+
update: PushRefUpdate
|
|
60
|
+
expression: str
|
|
61
|
+
strategy: str
|
|
62
|
+
|
|
63
|
+
|
|
20
64
|
@dataclass(frozen=True)
|
|
21
65
|
class GeneralConfig:
|
|
22
66
|
enabled: bool = True
|
|
@@ -29,7 +73,7 @@ class GeneralConfig:
|
|
|
29
73
|
@dataclass(frozen=True)
|
|
30
74
|
class LlmConfig:
|
|
31
75
|
runner: str = "opencode"
|
|
32
|
-
model: str = "openai/gpt-5.
|
|
76
|
+
model: str = "openai/gpt-5.6-terra"
|
|
33
77
|
variant: str = ""
|
|
34
78
|
timeout_seconds: int = 800
|
|
35
79
|
max_parallel: int = 2
|
|
@@ -177,8 +221,43 @@ class HookLogger:
|
|
|
177
221
|
return
|
|
178
222
|
record = {"ts": stamp, "level": level, "event": event, "message": message, **fields}
|
|
179
223
|
try:
|
|
180
|
-
|
|
181
|
-
|
|
224
|
+
try:
|
|
225
|
+
initial_metadata = self.jsonl_path.lstat()
|
|
226
|
+
except FileNotFoundError:
|
|
227
|
+
initial_metadata = None
|
|
228
|
+
if initial_metadata is not None:
|
|
229
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
230
|
+
if stat.S_ISLNK(initial_metadata.st_mode) or bool(
|
|
231
|
+
getattr(initial_metadata, "st_file_attributes", 0) & reparse_flag
|
|
232
|
+
):
|
|
233
|
+
raise HookError(
|
|
234
|
+
"JSONL log target must not be a symlink or reparse point: "
|
|
235
|
+
f"{self.jsonl_path}"
|
|
236
|
+
)
|
|
237
|
+
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
|
|
238
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
239
|
+
descriptor = os.open(self.jsonl_path, flags, 0o600)
|
|
240
|
+
try:
|
|
241
|
+
descriptor_metadata = os.fstat(descriptor)
|
|
242
|
+
path_metadata = self.jsonl_path.lstat()
|
|
243
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
244
|
+
if (
|
|
245
|
+
not stat.S_ISREG(descriptor_metadata.st_mode)
|
|
246
|
+
or stat.S_ISLNK(path_metadata.st_mode)
|
|
247
|
+
or bool(
|
|
248
|
+
getattr(path_metadata, "st_file_attributes", 0) & reparse_flag
|
|
249
|
+
)
|
|
250
|
+
or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
|
|
251
|
+
!= (path_metadata.st_dev, path_metadata.st_ino)
|
|
252
|
+
):
|
|
253
|
+
raise HookError(f"JSONL log target is not a regular file: {self.jsonl_path}")
|
|
254
|
+
os.fchmod(descriptor, 0o600)
|
|
255
|
+
os.write(
|
|
256
|
+
descriptor,
|
|
257
|
+
(json.dumps(record, ensure_ascii=True) + "\n").encode("utf-8"),
|
|
258
|
+
)
|
|
259
|
+
finally:
|
|
260
|
+
os.close(descriptor)
|
|
182
261
|
except Exception as exc: # noqa: BLE001
|
|
183
262
|
self.jsonl_write_failed = True
|
|
184
263
|
sys.stderr.write(f"[ai-push-hooks] JSONL logging disabled after write failure: {exc}\n")
|