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,775 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
5
|
+
import os
|
|
4
6
|
import pathlib
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
5
11
|
|
|
12
|
+
from ..paths import (
|
|
13
|
+
atomic_write_bytes,
|
|
14
|
+
ensure_private_directory,
|
|
15
|
+
is_path_within,
|
|
16
|
+
normalized_component,
|
|
17
|
+
path_has_symlink,
|
|
18
|
+
path_is_link_or_reparse,
|
|
19
|
+
relative_path_parts,
|
|
20
|
+
sanitize_file_mode,
|
|
21
|
+
)
|
|
6
22
|
from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
|
|
7
|
-
from .exec import
|
|
8
|
-
|
|
23
|
+
from .exec import (
|
|
24
|
+
list_repo_changes,
|
|
25
|
+
path_matches,
|
|
26
|
+
resolve_git_common_dir,
|
|
27
|
+
resolve_git_dir,
|
|
28
|
+
run_command,
|
|
29
|
+
)
|
|
30
|
+
from .llm import call_opencode, finalize_opencode_session, validate_opencode_attachments
|
|
9
31
|
|
|
32
|
+
METADATA_MAX_FILES = 20_000
|
|
33
|
+
METADATA_MAX_BYTES = 64 * 1024 * 1024
|
|
34
|
+
STAGING_MAX_FILES = 10_000
|
|
35
|
+
STAGING_MAX_BYTES = 256 * 1024 * 1024
|
|
36
|
+
SPECIAL_MODE_BITS = stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX
|
|
37
|
+
PROTECTED_GIT_COMPONENT = ".git"
|
|
38
|
+
PROTECTED_INSTRUCTION_FILENAME = "agents.md"
|
|
10
39
|
|
|
11
|
-
|
|
12
|
-
|
|
40
|
+
FileSnapshot = tuple[str, int | None, str | None]
|
|
41
|
+
MetadataSnapshot = dict[str, FileSnapshot]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class StagedFile:
|
|
46
|
+
digest: str
|
|
47
|
+
mode: int
|
|
48
|
+
size: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DestinationState:
|
|
53
|
+
kind: str
|
|
54
|
+
mode: int | None = None
|
|
55
|
+
digest: str | None = None
|
|
56
|
+
detail: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ApplyOperation:
|
|
61
|
+
relative_path: str
|
|
62
|
+
baseline: DestinationState
|
|
63
|
+
content: bytes | None
|
|
64
|
+
mode: int | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_protected_path(path: str) -> bool:
|
|
68
|
+
parts = pathlib.PurePosixPath(path).parts
|
|
69
|
+
return any(normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts) or (
|
|
70
|
+
bool(parts) and normalized_component(parts[-1]) == PROTECTED_INSTRUCTION_FILENAME
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _open_regular_file(path: pathlib.Path) -> tuple[int, os.stat_result]:
|
|
75
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
76
|
+
try:
|
|
77
|
+
descriptor = os.open(path, flags)
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise HookError(f"Unable to safely open regular file: {path}") from exc
|
|
80
|
+
metadata = os.fstat(descriptor)
|
|
81
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
82
|
+
os.close(descriptor)
|
|
83
|
+
raise HookError(f"Path is not a regular file: {path}")
|
|
84
|
+
return descriptor, metadata
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _hash_file(path: pathlib.Path, max_bytes: int | None = None) -> str:
|
|
88
|
+
digest = hashlib.sha256()
|
|
89
|
+
descriptor, metadata = _open_regular_file(path)
|
|
90
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
91
|
+
if max_bytes is not None and metadata.st_size > max_bytes:
|
|
92
|
+
raise HookError(f"File exceeds bounded read budget before reading: {path}")
|
|
93
|
+
total_bytes = 0
|
|
94
|
+
while True:
|
|
95
|
+
read_size = 1024 * 1024
|
|
96
|
+
if max_bytes is not None:
|
|
97
|
+
read_size = min(read_size, max_bytes - total_bytes + 1)
|
|
98
|
+
chunk = handle.read(read_size)
|
|
99
|
+
if not chunk:
|
|
100
|
+
break
|
|
101
|
+
total_bytes += len(chunk)
|
|
102
|
+
if max_bytes is not None and total_bytes > max_bytes:
|
|
103
|
+
raise HookError(f"File grew beyond bounded read budget while reading: {path}")
|
|
104
|
+
digest.update(chunk)
|
|
105
|
+
return digest.hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_regular_file(
|
|
109
|
+
path: pathlib.Path, *, max_bytes: int | None = None
|
|
110
|
+
) -> tuple[bytes, int]:
|
|
111
|
+
descriptor, metadata = _open_regular_file(path)
|
|
112
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
113
|
+
if max_bytes is not None and metadata.st_size > max_bytes:
|
|
114
|
+
raise HookError(f"File exceeds bounded read budget before reading: {path}")
|
|
115
|
+
content = handle.read() if max_bytes is None else handle.read(max_bytes + 1)
|
|
116
|
+
if max_bytes is not None and len(content) > max_bytes:
|
|
117
|
+
raise HookError(f"File grew beyond bounded read budget while reading: {path}")
|
|
118
|
+
return content, metadata.st_mode
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _snapshot_destination(repo_root: pathlib.Path, relative_path: str) -> DestinationState:
|
|
122
|
+
destination = _repo_path_from_git(repo_root, relative_path)
|
|
123
|
+
if path_has_symlink(repo_root, destination):
|
|
124
|
+
return DestinationState("symlink")
|
|
125
|
+
if not destination.exists():
|
|
126
|
+
return DestinationState("missing")
|
|
127
|
+
metadata = destination.lstat()
|
|
128
|
+
if stat.S_ISREG(metadata.st_mode):
|
|
129
|
+
return DestinationState(
|
|
130
|
+
"file", metadata.st_mode, _hash_file(destination, STAGING_MAX_BYTES)
|
|
131
|
+
)
|
|
132
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
133
|
+
return DestinationState("directory", metadata.st_mode)
|
|
134
|
+
return DestinationState("other", metadata.st_mode)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _repo_path_from_git(repo_root: pathlib.Path, path: str) -> pathlib.Path:
|
|
138
|
+
pure_path = pathlib.PurePosixPath(path)
|
|
139
|
+
if pure_path.is_absolute() or not pure_path.parts or ".." in pure_path.parts:
|
|
140
|
+
raise HookError(f"Git returned an unsafe repository path: {path!r}")
|
|
141
|
+
return repo_root.joinpath(*pure_path.parts)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _snapshot_repo_files(repo_root: pathlib.Path, paths: set[str]) -> dict[str, FileSnapshot]:
|
|
145
|
+
if len(paths) > STAGING_MAX_FILES:
|
|
146
|
+
raise HookError("Git-visible checkout changes exceed the bounded safety snapshot budget")
|
|
147
|
+
snapshot: dict[str, FileSnapshot] = {}
|
|
148
|
+
total_bytes = 0
|
|
13
149
|
for path in paths:
|
|
14
|
-
full_path = repo_root
|
|
15
|
-
if full_path
|
|
16
|
-
|
|
150
|
+
full_path = _repo_path_from_git(repo_root, path)
|
|
151
|
+
if path_has_symlink(repo_root, full_path):
|
|
152
|
+
mode = full_path.lstat().st_mode if full_path.exists() or full_path.is_symlink() else None
|
|
153
|
+
snapshot[path] = (
|
|
154
|
+
"symlink",
|
|
155
|
+
mode,
|
|
156
|
+
os.readlink(full_path) if full_path.is_symlink() else None,
|
|
157
|
+
)
|
|
158
|
+
elif full_path.exists():
|
|
159
|
+
metadata = full_path.lstat()
|
|
160
|
+
if stat.S_ISREG(metadata.st_mode):
|
|
161
|
+
total_bytes += metadata.st_size
|
|
162
|
+
if total_bytes > STAGING_MAX_BYTES:
|
|
163
|
+
raise HookError(
|
|
164
|
+
"Git-visible checkout changes exceed the bounded safety snapshot budget"
|
|
165
|
+
)
|
|
166
|
+
snapshot[path] = (
|
|
167
|
+
"file",
|
|
168
|
+
metadata.st_mode,
|
|
169
|
+
_hash_file(full_path, metadata.st_size),
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
snapshot[path] = ("other", metadata.st_mode, None)
|
|
173
|
+
else:
|
|
174
|
+
snapshot[path] = ("missing", None, None)
|
|
175
|
+
return snapshot
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _runtime_metadata_namespaces(context: RuntimeContext) -> tuple[pathlib.Path, ...]:
|
|
179
|
+
common_dir = resolve_git_common_dir(context.repo_root)
|
|
180
|
+
namespaces = tuple(
|
|
181
|
+
dict.fromkeys(
|
|
182
|
+
(
|
|
183
|
+
(context.git_dir.resolve() / "ai-push-hooks"),
|
|
184
|
+
(common_dir.resolve() / "ai-push-hooks"),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
for namespace in namespaces:
|
|
189
|
+
if path_is_link_or_reparse(namespace):
|
|
190
|
+
raise HookError(
|
|
191
|
+
"ai-push-hooks runtime metadata namespace is a symlink or reparse point: "
|
|
192
|
+
f"{namespace}"
|
|
193
|
+
)
|
|
194
|
+
return namespaces
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
|
|
198
|
+
git_dir = context.git_dir.resolve(strict=True)
|
|
199
|
+
common_dir = resolve_git_common_dir(context.repo_root).resolve(strict=True)
|
|
200
|
+
excluded_namespaces = _runtime_metadata_namespaces(context)
|
|
201
|
+
snapshot: MetadataSnapshot = {}
|
|
202
|
+
budget = {"entries": 0, "bytes": 0}
|
|
203
|
+
|
|
204
|
+
def excluded(path: pathlib.Path) -> bool:
|
|
205
|
+
lexical = pathlib.Path(os.path.abspath(path))
|
|
206
|
+
return any(is_path_within(lexical, namespace) for namespace in excluded_namespaces)
|
|
207
|
+
|
|
208
|
+
def record(key: str, path: pathlib.Path) -> None:
|
|
209
|
+
budget["entries"] += 1
|
|
210
|
+
if budget["entries"] > METADATA_MAX_FILES:
|
|
211
|
+
raise HookError("Git control metadata exceeds the bounded safety snapshot budget")
|
|
212
|
+
if path_is_link_or_reparse(path):
|
|
213
|
+
raise HookError(
|
|
214
|
+
f"Refusing symlinked monitored Git metadata or reparse point: {key} ({path})"
|
|
215
|
+
)
|
|
216
|
+
elif not path.exists():
|
|
217
|
+
snapshot[key] = ("missing", None, None)
|
|
17
218
|
else:
|
|
18
|
-
|
|
219
|
+
metadata = path.lstat()
|
|
220
|
+
detail: str | None = None
|
|
221
|
+
if stat.S_ISREG(metadata.st_mode):
|
|
222
|
+
budget["bytes"] += metadata.st_size
|
|
223
|
+
if budget["bytes"] > METADATA_MAX_BYTES:
|
|
224
|
+
raise HookError(
|
|
225
|
+
"Git control metadata exceeds the bounded safety snapshot budget"
|
|
226
|
+
)
|
|
227
|
+
detail = _hash_file(path, metadata.st_size)
|
|
228
|
+
snapshot[key] = ("metadata", metadata.st_mode, detail)
|
|
229
|
+
|
|
230
|
+
def scan_tree(
|
|
231
|
+
label: str,
|
|
232
|
+
root: pathlib.Path,
|
|
233
|
+
*,
|
|
234
|
+
pruned_top_level: set[str] | None = None,
|
|
235
|
+
skipped_root_files: set[str] | None = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
if not root.exists() and not root.is_symlink():
|
|
238
|
+
record(f"{label}:.", root)
|
|
239
|
+
return
|
|
240
|
+
record(f"{label}:.", root)
|
|
241
|
+
if path_is_link_or_reparse(root) or not root.is_dir():
|
|
242
|
+
return
|
|
243
|
+
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
|
244
|
+
directory_path = pathlib.Path(directory)
|
|
245
|
+
relative_directory = directory_path.relative_to(root)
|
|
246
|
+
retained: list[str] = []
|
|
247
|
+
for name in dirnames:
|
|
248
|
+
path = directory_path / name
|
|
249
|
+
if excluded(path) or (
|
|
250
|
+
not relative_directory.parts
|
|
251
|
+
and pruned_top_level is not None
|
|
252
|
+
and name in pruned_top_level
|
|
253
|
+
):
|
|
254
|
+
continue
|
|
255
|
+
record(f"{label}:{path.relative_to(root).as_posix()}", path)
|
|
256
|
+
if not path_is_link_or_reparse(path):
|
|
257
|
+
retained.append(name)
|
|
258
|
+
dirnames[:] = retained
|
|
259
|
+
for name in filenames:
|
|
260
|
+
path = directory_path / name
|
|
261
|
+
if excluded(path) or (
|
|
262
|
+
not relative_directory.parts
|
|
263
|
+
and skipped_root_files is not None
|
|
264
|
+
and name in skipped_root_files
|
|
265
|
+
):
|
|
266
|
+
continue
|
|
267
|
+
record(f"{label}:{path.relative_to(root).as_posix()}", path)
|
|
268
|
+
|
|
269
|
+
if git_dir == common_dir:
|
|
270
|
+
scan_tree(
|
|
271
|
+
"current",
|
|
272
|
+
git_dir,
|
|
273
|
+
pruned_top_level={
|
|
274
|
+
"ai-push-hooks",
|
|
275
|
+
"branches",
|
|
276
|
+
"hooks",
|
|
277
|
+
"lfs",
|
|
278
|
+
"logs",
|
|
279
|
+
"objects",
|
|
280
|
+
"refs",
|
|
281
|
+
"worktrees",
|
|
282
|
+
},
|
|
283
|
+
skipped_root_files={"HEAD", "config", "config.worktree", "index", "packed-refs"},
|
|
284
|
+
)
|
|
285
|
+
record("current:logs/HEAD", common_dir / "logs" / "HEAD")
|
|
286
|
+
else:
|
|
287
|
+
scan_tree(
|
|
288
|
+
"current",
|
|
289
|
+
git_dir,
|
|
290
|
+
pruned_top_level={"ai-push-hooks", "lfs", "objects"},
|
|
291
|
+
skipped_root_files={"index"},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
for name in ("HEAD", "config", "config.worktree", "packed-refs"):
|
|
295
|
+
record(f"shared:{name}", common_dir / name)
|
|
296
|
+
scan_tree("shared:refs", common_dir / "refs")
|
|
297
|
+
raw_hooks_path = run_command(
|
|
298
|
+
["git", "rev-parse", "--git-path", "hooks"],
|
|
299
|
+
cwd=context.repo_root,
|
|
300
|
+
check=True,
|
|
301
|
+
).stdout.strip()
|
|
302
|
+
hooks_path = pathlib.Path(raw_hooks_path)
|
|
303
|
+
if not hooks_path.is_absolute():
|
|
304
|
+
hooks_path = context.repo_root / hooks_path
|
|
305
|
+
hooks_path = pathlib.Path(os.path.abspath(hooks_path))
|
|
306
|
+
if any(is_path_within(hooks_path, namespace) for namespace in excluded_namespaces):
|
|
307
|
+
raise HookError(
|
|
308
|
+
"Configured core.hooksPath must not overlap ai-push-hooks runtime metadata: "
|
|
309
|
+
f"{hooks_path}"
|
|
310
|
+
)
|
|
311
|
+
scan_tree("shared:hooks", hooks_path)
|
|
19
312
|
return snapshot
|
|
20
313
|
|
|
21
314
|
|
|
315
|
+
def _git_index_state(repo_root: pathlib.Path) -> tuple[str, str]:
|
|
316
|
+
staged = run_command(
|
|
317
|
+
["git", "ls-files", "--stage", "-z"], cwd=repo_root, check=True
|
|
318
|
+
).stdout
|
|
319
|
+
flags = run_command(["git", "ls-files", "-v", "-z"], cwd=repo_root, check=True).stdout
|
|
320
|
+
return staged, flags
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate_apply_allowlist(repo_root: pathlib.Path, patterns: tuple[str, ...]) -> None:
|
|
324
|
+
for pattern in patterns:
|
|
325
|
+
parts = relative_path_parts(pattern, "Apply allow_paths entry")
|
|
326
|
+
if any(normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts):
|
|
327
|
+
raise HookError("Apply allow_paths must not include Git metadata")
|
|
328
|
+
if normalized_component(parts[-1]) == PROTECTED_INSTRUCTION_FILENAME:
|
|
329
|
+
raise HookError("Apply allow_paths must not include AGENTS.md")
|
|
330
|
+
static_parts: list[str] = []
|
|
331
|
+
for part in parts:
|
|
332
|
+
if any(character in part for character in "*?["):
|
|
333
|
+
break
|
|
334
|
+
static_parts.append(part)
|
|
335
|
+
if len(static_parts) == len(parts):
|
|
336
|
+
candidates = [repo_root.joinpath(*parts)]
|
|
337
|
+
else:
|
|
338
|
+
candidates = [repo_root.joinpath(*static_parts)] if static_parts else [repo_root]
|
|
339
|
+
for candidate in candidates:
|
|
340
|
+
if path_has_symlink(repo_root, candidate):
|
|
341
|
+
raise HookError(f"Apply allow_paths traverses a symlink: {pattern}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _tracked_and_unignored_paths(repo_root: pathlib.Path) -> set[str]:
|
|
345
|
+
output = run_command(
|
|
346
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
347
|
+
cwd=repo_root,
|
|
348
|
+
check=True,
|
|
349
|
+
).stdout
|
|
350
|
+
return {path for path in output.split("\x00") if path}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _copy_checkout_to_staging(
|
|
354
|
+
context: RuntimeContext,
|
|
355
|
+
staging_root: pathlib.Path,
|
|
356
|
+
allow_paths: tuple[str, ...],
|
|
357
|
+
) -> dict[str, DestinationState]:
|
|
358
|
+
repo_root = context.repo_root
|
|
359
|
+
git_roots = (
|
|
360
|
+
context.git_dir.resolve(strict=True),
|
|
361
|
+
resolve_git_common_dir(repo_root).resolve(strict=True),
|
|
362
|
+
)
|
|
363
|
+
copied_files = 0
|
|
364
|
+
copied_bytes = 0
|
|
365
|
+
baselines: dict[str, DestinationState] = {}
|
|
366
|
+
resolved_repo_root = repo_root.resolve(strict=True)
|
|
367
|
+
allowed_paths = {
|
|
368
|
+
relative_path
|
|
369
|
+
for relative_path in _tracked_and_unignored_paths(repo_root)
|
|
370
|
+
if not _is_protected_path(relative_path)
|
|
371
|
+
and any(path_matches(relative_path, pattern) for pattern in allow_paths)
|
|
372
|
+
}
|
|
373
|
+
allowed_paths -= _ignored_changed_paths(repo_root, allowed_paths)
|
|
374
|
+
for relative_path in sorted(allowed_paths):
|
|
375
|
+
source = _repo_path_from_git(repo_root, relative_path)
|
|
376
|
+
if path_has_symlink(repo_root, source):
|
|
377
|
+
raise HookError(
|
|
378
|
+
f"Allowed checkout path is or traverses a symlink or reparse point: {relative_path}"
|
|
379
|
+
)
|
|
380
|
+
resolved_source = source.resolve(strict=False)
|
|
381
|
+
if not is_path_within(resolved_source, resolved_repo_root):
|
|
382
|
+
raise HookError(f"Allowed checkout source escapes repository: {relative_path}")
|
|
383
|
+
if any(is_path_within(resolved_source, root) for root in git_roots):
|
|
384
|
+
continue
|
|
385
|
+
if not source.exists():
|
|
386
|
+
continue
|
|
387
|
+
if not source.is_file():
|
|
388
|
+
raise HookError(f"Allowed checkout path is not a regular file: {relative_path}")
|
|
389
|
+
copied_files += 1
|
|
390
|
+
if copied_files > STAGING_MAX_FILES:
|
|
391
|
+
raise HookError("Allowed apply files exceed the bounded staging workspace budget")
|
|
392
|
+
remaining_bytes = STAGING_MAX_BYTES - copied_bytes
|
|
393
|
+
content, source_mode = _read_regular_file(source, max_bytes=remaining_bytes)
|
|
394
|
+
copied_bytes += len(content)
|
|
395
|
+
if copied_bytes > STAGING_MAX_BYTES:
|
|
396
|
+
raise HookError("Allowed apply files exceed the bounded staging workspace budget")
|
|
397
|
+
baselines[relative_path] = DestinationState(
|
|
398
|
+
"file",
|
|
399
|
+
source_mode,
|
|
400
|
+
hashlib.sha256(content).hexdigest(),
|
|
401
|
+
)
|
|
402
|
+
destination = staging_root.joinpath(*pathlib.PurePosixPath(relative_path).parts)
|
|
403
|
+
ensure_private_directory(destination.parent, private_root=staging_root)
|
|
404
|
+
atomic_write_bytes(destination, content, mode=source_mode)
|
|
405
|
+
return baselines
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _inventory_staging(staging_root: pathlib.Path) -> dict[str, StagedFile]:
|
|
409
|
+
inventory: dict[str, StagedFile] = {}
|
|
410
|
+
total_bytes = 0
|
|
411
|
+
total_entries = 0
|
|
412
|
+
for directory, dirnames, filenames in os.walk(staging_root, followlinks=False):
|
|
413
|
+
total_entries += len(dirnames) + len(filenames)
|
|
414
|
+
if total_entries > STAGING_MAX_FILES:
|
|
415
|
+
raise HookError("Apply staging workspace exceeds its bounded inventory budget")
|
|
416
|
+
directory_path = pathlib.Path(directory)
|
|
417
|
+
for name in dirnames:
|
|
418
|
+
path = directory_path / name
|
|
419
|
+
if path_is_link_or_reparse(path):
|
|
420
|
+
relative = path.relative_to(staging_root).as_posix()
|
|
421
|
+
raise HookError(
|
|
422
|
+
f"Apply staging workspace contains symlink or reparse point: {relative}"
|
|
423
|
+
)
|
|
424
|
+
for name in filenames:
|
|
425
|
+
path = directory_path / name
|
|
426
|
+
relative = path.relative_to(staging_root).as_posix()
|
|
427
|
+
metadata = path.lstat()
|
|
428
|
+
if path_is_link_or_reparse(path):
|
|
429
|
+
raise HookError(
|
|
430
|
+
f"Apply staging workspace contains symlink or reparse point: {relative}"
|
|
431
|
+
)
|
|
432
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
433
|
+
raise HookError(f"Apply staging workspace contains non-regular file: {relative}")
|
|
434
|
+
total_bytes += metadata.st_size
|
|
435
|
+
if len(inventory) >= STAGING_MAX_FILES or total_bytes > STAGING_MAX_BYTES:
|
|
436
|
+
raise HookError("Apply staging workspace exceeds its bounded inventory budget")
|
|
437
|
+
inventory[relative] = StagedFile(
|
|
438
|
+
_hash_file(path, metadata.st_size), metadata.st_mode, metadata.st_size
|
|
439
|
+
)
|
|
440
|
+
return inventory
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _changed_staging_paths(
|
|
444
|
+
before: dict[str, StagedFile],
|
|
445
|
+
after: dict[str, StagedFile],
|
|
446
|
+
allow_paths: tuple[str, ...],
|
|
447
|
+
) -> set[str]:
|
|
448
|
+
all_paths = set(before) | set(after)
|
|
449
|
+
unexpected = sorted(
|
|
450
|
+
path
|
|
451
|
+
for path in all_paths
|
|
452
|
+
if _is_protected_path(path)
|
|
453
|
+
or not any(path_matches(path, pattern) for pattern in allow_paths)
|
|
454
|
+
)
|
|
455
|
+
if unexpected:
|
|
456
|
+
raise HookError("Apply staging workspace contains paths outside allowlist: " + ", ".join(unexpected))
|
|
457
|
+
return {path for path in all_paths if before.get(path) != after.get(path)}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _ignored_changed_paths(
|
|
461
|
+
repo_root: pathlib.Path,
|
|
462
|
+
paths: set[str],
|
|
463
|
+
*,
|
|
464
|
+
work_tree: pathlib.Path | None = None,
|
|
465
|
+
) -> set[str]:
|
|
466
|
+
if not paths:
|
|
467
|
+
return set()
|
|
468
|
+
command = ["git"]
|
|
469
|
+
if work_tree is not None:
|
|
470
|
+
command.extend(
|
|
471
|
+
[
|
|
472
|
+
f"--git-dir={resolve_git_dir(repo_root)}",
|
|
473
|
+
f"--work-tree={work_tree}",
|
|
474
|
+
]
|
|
475
|
+
)
|
|
476
|
+
command.extend(["check-ignore", "--no-index", "--stdin", "-z"])
|
|
477
|
+
completed = run_command(
|
|
478
|
+
command,
|
|
479
|
+
cwd=repo_root,
|
|
480
|
+
input_text="\x00".join(sorted(paths)) + "\x00",
|
|
481
|
+
check=False,
|
|
482
|
+
)
|
|
483
|
+
if completed.returncode not in {0, 1}:
|
|
484
|
+
raise HookError((completed.stderr or "").strip() or "git check-ignore failed")
|
|
485
|
+
return {path for path in completed.stdout.split("\x00") if path}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Path:
|
|
489
|
+
repo_root = context.repo_root.resolve(strict=True)
|
|
490
|
+
if _is_protected_path(relative_path):
|
|
491
|
+
raise HookError(f"Apply destination must not contain Git metadata: {relative_path}")
|
|
492
|
+
destination = _repo_path_from_git(repo_root, relative_path)
|
|
493
|
+
if path_has_symlink(repo_root, destination):
|
|
494
|
+
raise HookError(f"Apply destination is or traverses a symlink: {relative_path}")
|
|
495
|
+
existing_parent = destination.parent
|
|
496
|
+
while not existing_parent.exists() and existing_parent != repo_root:
|
|
497
|
+
existing_parent = existing_parent.parent
|
|
498
|
+
if not existing_parent.is_dir():
|
|
499
|
+
raise HookError(f"Apply destination has a non-directory parent: {relative_path}")
|
|
500
|
+
if not is_path_within(existing_parent.resolve(strict=True), repo_root.resolve(strict=True)):
|
|
501
|
+
raise HookError(f"Apply destination escapes repository: {relative_path}")
|
|
502
|
+
resolved_destination = destination.resolve(strict=False)
|
|
503
|
+
git_roots = (
|
|
504
|
+
context.git_dir.resolve(strict=True),
|
|
505
|
+
resolve_git_common_dir(context.repo_root).resolve(strict=True),
|
|
506
|
+
)
|
|
507
|
+
if any(is_path_within(resolved_destination, root) for root in git_roots):
|
|
508
|
+
raise HookError(f"Apply destination resolves inside Git metadata: {relative_path}")
|
|
509
|
+
return destination
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _conservative_propagation_mode(
|
|
513
|
+
baseline: DestinationState,
|
|
514
|
+
source_mode: int,
|
|
515
|
+
) -> int:
|
|
516
|
+
source_permissions = sanitize_file_mode(source_mode)
|
|
517
|
+
if baseline.kind == "file" and baseline.mode is not None:
|
|
518
|
+
existing_permissions = sanitize_file_mode(baseline.mode)
|
|
519
|
+
if existing_permissions & 0o022 == 0:
|
|
520
|
+
return existing_permissions
|
|
521
|
+
source_permissions = existing_permissions
|
|
522
|
+
return 0o700 if source_permissions & 0o100 else 0o600
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _preflight_apply_operations(
|
|
526
|
+
context: RuntimeContext,
|
|
527
|
+
operations: list[ApplyOperation],
|
|
528
|
+
) -> None:
|
|
529
|
+
conflicts = [
|
|
530
|
+
operation.relative_path
|
|
531
|
+
for operation in operations
|
|
532
|
+
if _snapshot_destination(context.repo_root, operation.relative_path) != operation.baseline
|
|
533
|
+
]
|
|
534
|
+
if conflicts:
|
|
535
|
+
raise HookError(
|
|
536
|
+
"Apply checkout changed concurrently; refusing to overwrite: " + ", ".join(conflicts)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _verify_operation_baseline(context: RuntimeContext, operation: ApplyOperation) -> None:
|
|
541
|
+
if _snapshot_destination(context.repo_root, operation.relative_path) != operation.baseline:
|
|
542
|
+
raise HookError(
|
|
543
|
+
"Apply checkout changed concurrently; refusing to overwrite: "
|
|
544
|
+
+ operation.relative_path
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _propagate_staging_changes(
|
|
549
|
+
context: RuntimeContext,
|
|
550
|
+
staging_root: pathlib.Path,
|
|
551
|
+
after: dict[str, StagedFile],
|
|
552
|
+
changed_paths: set[str],
|
|
553
|
+
baselines: dict[str, DestinationState],
|
|
554
|
+
) -> dict[str, StagedFile | None]:
|
|
555
|
+
ignored = _ignored_changed_paths(context.repo_root, changed_paths)
|
|
556
|
+
ignored.update(
|
|
557
|
+
_ignored_changed_paths(context.repo_root, changed_paths, work_tree=staging_root)
|
|
558
|
+
)
|
|
559
|
+
if ignored:
|
|
560
|
+
raise HookError("Refusing to copy staging output to ignored paths: " + ", ".join(sorted(ignored)))
|
|
561
|
+
operations: list[ApplyOperation] = []
|
|
562
|
+
expected: dict[str, StagedFile | None] = {}
|
|
563
|
+
for relative_path in sorted(changed_paths):
|
|
564
|
+
_safe_destination(context, relative_path)
|
|
565
|
+
baseline = baselines.get(relative_path, DestinationState("missing"))
|
|
566
|
+
staged = after.get(relative_path)
|
|
567
|
+
if staged is None:
|
|
568
|
+
if baseline.kind != "file":
|
|
569
|
+
raise HookError(f"Refusing unsafe staged deletion: {relative_path}")
|
|
570
|
+
operations.append(ApplyOperation(relative_path, baseline, None, None))
|
|
571
|
+
expected[relative_path] = None
|
|
572
|
+
continue
|
|
573
|
+
if staged.mode & SPECIAL_MODE_BITS:
|
|
574
|
+
raise HookError(
|
|
575
|
+
f"Refusing staged output with setuid, setgid, or sticky mode bits: {relative_path}"
|
|
576
|
+
)
|
|
577
|
+
source = staging_root.joinpath(*pathlib.PurePosixPath(relative_path).parts)
|
|
578
|
+
if path_has_symlink(staging_root, source) or not source.is_file():
|
|
579
|
+
raise HookError(f"Refusing unsafe staged output: {relative_path}")
|
|
580
|
+
if not is_path_within(
|
|
581
|
+
source.resolve(strict=True), staging_root.resolve(strict=True)
|
|
582
|
+
):
|
|
583
|
+
raise HookError(f"Refusing staged output that escapes workspace: {relative_path}")
|
|
584
|
+
content, source_mode = _read_regular_file(source, max_bytes=staged.size)
|
|
585
|
+
if (
|
|
586
|
+
hashlib.sha256(content).hexdigest() != staged.digest
|
|
587
|
+
or stat.S_IMODE(source_mode) != stat.S_IMODE(staged.mode)
|
|
588
|
+
):
|
|
589
|
+
raise HookError(f"Staged output changed after validation: {relative_path}")
|
|
590
|
+
approved_mode = _conservative_propagation_mode(baseline, staged.mode)
|
|
591
|
+
operations.append(
|
|
592
|
+
ApplyOperation(relative_path, baseline, content, approved_mode)
|
|
593
|
+
)
|
|
594
|
+
expected[relative_path] = StagedFile(staged.digest, approved_mode, staged.size)
|
|
595
|
+
|
|
596
|
+
_preflight_apply_operations(context, operations)
|
|
597
|
+
applied_paths: list[str] = []
|
|
598
|
+
try:
|
|
599
|
+
for operation in operations:
|
|
600
|
+
destination = _safe_destination(context, operation.relative_path)
|
|
601
|
+
_verify_operation_baseline(context, operation)
|
|
602
|
+
if operation.content is None or operation.mode is None:
|
|
603
|
+
destination.unlink()
|
|
604
|
+
applied_paths.append(operation.relative_path)
|
|
605
|
+
continue
|
|
606
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
607
|
+
destination = _safe_destination(context, operation.relative_path)
|
|
608
|
+
_verify_operation_baseline(context, operation)
|
|
609
|
+
atomic_write_bytes(destination, operation.content, mode=operation.mode)
|
|
610
|
+
applied_paths.append(operation.relative_path)
|
|
611
|
+
except Exception as exc: # noqa: BLE001
|
|
612
|
+
applied = ", ".join(applied_paths) if applied_paths else "<none>"
|
|
613
|
+
raise HookError(
|
|
614
|
+
f"Apply propagation failed; already-applied paths: {applied}; error: {exc}"
|
|
615
|
+
) from exc
|
|
616
|
+
return expected
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _verify_propagated_changes(
|
|
620
|
+
repo_root: pathlib.Path,
|
|
621
|
+
expected: dict[str, StagedFile | None],
|
|
622
|
+
) -> None:
|
|
623
|
+
mismatches: list[str] = []
|
|
624
|
+
for relative_path, staged in sorted(expected.items()):
|
|
625
|
+
destination = _repo_path_from_git(repo_root, relative_path)
|
|
626
|
+
if staged is None:
|
|
627
|
+
if destination.exists() or destination.is_symlink():
|
|
628
|
+
mismatches.append(relative_path)
|
|
629
|
+
continue
|
|
630
|
+
if path_has_symlink(repo_root, destination) or not destination.is_file():
|
|
631
|
+
mismatches.append(relative_path)
|
|
632
|
+
continue
|
|
633
|
+
metadata = destination.lstat()
|
|
634
|
+
if _hash_file(destination, staged.size) != staged.digest or stat.S_IMODE(metadata.st_mode) != stat.S_IMODE(
|
|
635
|
+
staged.mode
|
|
636
|
+
):
|
|
637
|
+
mismatches.append(relative_path)
|
|
638
|
+
if mismatches:
|
|
639
|
+
raise HookError(
|
|
640
|
+
"Real checkout does not match validated staging output: " + ", ".join(mismatches)
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _metadata_changes(before: MetadataSnapshot, after: MetadataSnapshot) -> list[str]:
|
|
645
|
+
return sorted(
|
|
646
|
+
path for path in set(before) | set(after) if before.get(path) != after.get(path)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _checkout_changes_from_baseline(
|
|
651
|
+
repo_root: pathlib.Path,
|
|
652
|
+
baseline: set[str],
|
|
653
|
+
baseline_contents: dict[str, FileSnapshot],
|
|
654
|
+
) -> tuple[set[str], set[str]]:
|
|
655
|
+
current = list_repo_changes(repo_root)
|
|
656
|
+
current_baseline_contents = _snapshot_repo_files(repo_root, baseline)
|
|
657
|
+
content_changes = {
|
|
658
|
+
path
|
|
659
|
+
for path, before_content in baseline_contents.items()
|
|
660
|
+
if before_content != current_baseline_contents[path]
|
|
661
|
+
}
|
|
662
|
+
return current, content_changes
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _verify_pre_propagation_security_state(
|
|
666
|
+
context: RuntimeContext,
|
|
667
|
+
baseline: set[str],
|
|
668
|
+
baseline_contents: dict[str, FileSnapshot],
|
|
669
|
+
index_before: tuple[str, str],
|
|
670
|
+
metadata_before: MetadataSnapshot,
|
|
671
|
+
) -> None:
|
|
672
|
+
current, content_changes = _checkout_changes_from_baseline(
|
|
673
|
+
context.repo_root, baseline, baseline_contents
|
|
674
|
+
)
|
|
675
|
+
checkout_changes = sorted((current ^ baseline) | content_changes)
|
|
676
|
+
if checkout_changes:
|
|
677
|
+
raise HookError(
|
|
678
|
+
"Apply modified the real checkout before propagation: "
|
|
679
|
+
+ ", ".join(checkout_changes)
|
|
680
|
+
+ ". Changes were not reverted; review them manually."
|
|
681
|
+
)
|
|
682
|
+
if _git_index_state(context.repo_root) != index_before:
|
|
683
|
+
raise HookError(
|
|
684
|
+
"Apply modified the Git index before propagation. "
|
|
685
|
+
"Changes were not reverted; review them manually."
|
|
686
|
+
)
|
|
687
|
+
changed_metadata = _metadata_changes(
|
|
688
|
+
metadata_before, _snapshot_git_control_metadata(context)
|
|
689
|
+
)
|
|
690
|
+
if changed_metadata:
|
|
691
|
+
raise HookError(
|
|
692
|
+
"Apply modified Git control metadata before propagation: "
|
|
693
|
+
+ ", ".join(changed_metadata[:20])
|
|
694
|
+
+ ". Changes were not reverted; review them manually."
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _verify_post_propagation_security_state(
|
|
699
|
+
context: RuntimeContext,
|
|
700
|
+
baseline: set[str],
|
|
701
|
+
baseline_contents: dict[str, FileSnapshot],
|
|
702
|
+
index_before: tuple[str, str],
|
|
703
|
+
metadata_before: MetadataSnapshot,
|
|
704
|
+
staged_changes: set[str],
|
|
705
|
+
propagated_expected: dict[str, StagedFile | None],
|
|
706
|
+
) -> None:
|
|
707
|
+
_verify_propagated_changes(context.repo_root, propagated_expected)
|
|
708
|
+
current, content_changes = _checkout_changes_from_baseline(
|
|
709
|
+
context.repo_root, baseline, baseline_contents
|
|
710
|
+
)
|
|
711
|
+
changed_files = (current - baseline) | content_changes
|
|
712
|
+
unapproved_real_changes = sorted(changed_files - staged_changes)
|
|
713
|
+
if unapproved_real_changes:
|
|
714
|
+
raise HookError(
|
|
715
|
+
"Apply modified the real checkout outside validated staging propagation: "
|
|
716
|
+
+ ", ".join(unapproved_real_changes)
|
|
717
|
+
+ ". Changes were not reverted; review them manually."
|
|
718
|
+
)
|
|
719
|
+
if _git_index_state(context.repo_root) != index_before:
|
|
720
|
+
raise HookError(
|
|
721
|
+
"Apply modified the Git index after propagation. "
|
|
722
|
+
"Changes were not reverted; review them manually."
|
|
723
|
+
)
|
|
724
|
+
changed_metadata = _metadata_changes(
|
|
725
|
+
metadata_before, _snapshot_git_control_metadata(context)
|
|
726
|
+
)
|
|
727
|
+
if changed_metadata:
|
|
728
|
+
raise HookError(
|
|
729
|
+
"Apply modified Git control metadata after propagation: "
|
|
730
|
+
+ ", ".join(changed_metadata[:20])
|
|
731
|
+
+ ". Changes were not reverted; review them manually."
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _apply_prompt(prompt: str, allow_paths: tuple[str, ...]) -> str:
|
|
736
|
+
rendered_paths = "\n".join(f"- {pattern}" for pattern in allow_paths)
|
|
737
|
+
return (
|
|
738
|
+
prompt.rstrip()
|
|
739
|
+
+ "\n\nMANDATORY STAGING WRITE BOUNDARY:\n"
|
|
740
|
+
+ "This workspace contains only approved files. Modify only paths matching:\n"
|
|
741
|
+
+ rendered_paths
|
|
742
|
+
+ "\nDo not create symlinks. Do not use commands, tasks, web access, or external paths.\n"
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _assert_apply_targets_checked_out_head(context: RuntimeContext) -> None:
|
|
747
|
+
updates = context.cache.get("pushed_branch_updates")
|
|
748
|
+
if updates is None:
|
|
749
|
+
updates = [
|
|
750
|
+
update
|
|
751
|
+
for update in context.cache.get("push_updates", [])
|
|
752
|
+
if update.ref_kind == "branch" and update.operation != "delete"
|
|
753
|
+
]
|
|
754
|
+
if not isinstance(updates, (list, tuple)) or len(updates) != 1:
|
|
755
|
+
raise HookError("Apply requires exactly one non-deletion pushed branch update")
|
|
756
|
+
update = updates[0]
|
|
757
|
+
head = run_command(
|
|
758
|
+
["git", "rev-parse", "--verify", "HEAD^{commit}"],
|
|
759
|
+
cwd=context.repo_root,
|
|
760
|
+
check=True,
|
|
761
|
+
).stdout.strip()
|
|
762
|
+
local_commit = run_command(
|
|
763
|
+
["git", "rev-parse", "--verify", "--quiet", f"{update.local_sha}^{{commit}}"],
|
|
764
|
+
cwd=context.repo_root,
|
|
765
|
+
check=False,
|
|
766
|
+
).stdout.strip()
|
|
767
|
+
if not local_commit or local_commit != head:
|
|
768
|
+
raise HookError(
|
|
769
|
+
"Apply requires the single pushed branch's local commit to be the checked-out HEAD"
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
22
773
|
def run_apply_step(
|
|
23
774
|
context: RuntimeContext,
|
|
24
775
|
state: ModuleRuntimeState,
|
|
@@ -27,48 +778,99 @@ def run_apply_step(
|
|
|
27
778
|
input_paths: list[pathlib.Path],
|
|
28
779
|
stage_name: str,
|
|
29
780
|
) -> dict[str, object]:
|
|
30
|
-
|
|
781
|
+
validated_inputs = validate_opencode_attachments(context, input_paths)
|
|
782
|
+
for input_path in validated_inputs:
|
|
31
783
|
if input_path.name.endswith("issues.json"):
|
|
32
784
|
issues = json.loads(input_path.read_text(encoding="utf-8"))
|
|
33
785
|
if isinstance(issues, list) and not issues:
|
|
34
786
|
return {"changed": False, "changed_files": [], "skipped": True}
|
|
35
787
|
|
|
788
|
+
_assert_apply_targets_checked_out_head(context)
|
|
789
|
+
_validate_apply_allowlist(context.repo_root, step.allow_paths)
|
|
36
790
|
baseline = list_repo_changes(context.repo_root)
|
|
37
|
-
baseline_contents =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
791
|
+
baseline_contents = _snapshot_repo_files(context.repo_root, baseline)
|
|
792
|
+
index_before = _git_index_state(context.repo_root)
|
|
793
|
+
metadata_before = _snapshot_git_control_metadata(context)
|
|
794
|
+
|
|
795
|
+
result: Any | None = None
|
|
796
|
+
call_error: Exception | None = None
|
|
797
|
+
staged_changes: set[str] = set()
|
|
798
|
+
propagated_expected: dict[str, StagedFile | None] = {}
|
|
799
|
+
with tempfile.TemporaryDirectory(prefix="ai-push-hooks-apply-") as temporary_directory:
|
|
800
|
+
staging_root = pathlib.Path(temporary_directory).resolve(strict=True)
|
|
801
|
+
destination_baselines = _copy_checkout_to_staging(context, staging_root, step.allow_paths)
|
|
802
|
+
staged_before = _inventory_staging(staging_root)
|
|
803
|
+
try:
|
|
804
|
+
result = call_opencode(
|
|
805
|
+
context,
|
|
806
|
+
stage_name=stage_name,
|
|
807
|
+
purpose=f"{step.type}:{step.id}",
|
|
808
|
+
prompt=_apply_prompt(prompt, step.allow_paths),
|
|
809
|
+
files=validated_inputs,
|
|
810
|
+
agent="apply",
|
|
811
|
+
allow_paths=step.allow_paths,
|
|
812
|
+
working_directory=staging_root,
|
|
813
|
+
)
|
|
814
|
+
except Exception as exc: # noqa: BLE001
|
|
815
|
+
call_error = exc
|
|
816
|
+
|
|
817
|
+
try:
|
|
818
|
+
staged_after = _inventory_staging(staging_root)
|
|
819
|
+
staged_changes = _changed_staging_paths(
|
|
820
|
+
staged_before, staged_after, step.allow_paths
|
|
821
|
+
)
|
|
822
|
+
except Exception as exc: # noqa: BLE001
|
|
823
|
+
if call_error is None:
|
|
824
|
+
call_error = exc
|
|
825
|
+
|
|
826
|
+
if result is not None:
|
|
827
|
+
try:
|
|
828
|
+
finalize_opencode_session(context, stage_name, result.session_id)
|
|
829
|
+
except Exception as exc: # noqa: BLE001
|
|
830
|
+
if call_error is None:
|
|
831
|
+
call_error = exc
|
|
832
|
+
|
|
833
|
+
_verify_pre_propagation_security_state(
|
|
834
|
+
context,
|
|
835
|
+
baseline,
|
|
836
|
+
baseline_contents,
|
|
837
|
+
index_before,
|
|
838
|
+
metadata_before,
|
|
839
|
+
)
|
|
840
|
+
if call_error is not None:
|
|
841
|
+
raise HookError(f"Apply step failed in isolated staging: {call_error}") from call_error
|
|
842
|
+
if result is None:
|
|
843
|
+
raise HookError("Apply step failed without an OpenCode result")
|
|
844
|
+
if result.return_code != 0:
|
|
845
|
+
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.return_code}"
|
|
846
|
+
raise HookError(f"Apply step failed in isolated staging: {details}")
|
|
847
|
+
propagated_expected = _propagate_staging_changes(
|
|
848
|
+
context,
|
|
849
|
+
staging_root,
|
|
850
|
+
staged_after,
|
|
851
|
+
staged_changes,
|
|
852
|
+
destination_baselines,
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
try:
|
|
856
|
+
_verify_post_propagation_security_state(
|
|
857
|
+
context,
|
|
858
|
+
baseline,
|
|
859
|
+
baseline_contents,
|
|
860
|
+
index_before,
|
|
861
|
+
metadata_before,
|
|
862
|
+
staged_changes,
|
|
863
|
+
propagated_expected,
|
|
864
|
+
)
|
|
865
|
+
except Exception as exc: # noqa: BLE001
|
|
866
|
+
applied = ", ".join(sorted(propagated_expected)) or "<none>"
|
|
867
|
+
raise HookError(
|
|
868
|
+
"Apply post-propagation verification failed; "
|
|
869
|
+
f"already-applied paths: {applied}; error: {exc}"
|
|
870
|
+
) from exc
|
|
69
871
|
return {
|
|
70
|
-
"changed": bool(
|
|
71
|
-
"changed_files":
|
|
872
|
+
"changed": bool(staged_changes),
|
|
873
|
+
"changed_files": sorted(staged_changes),
|
|
72
874
|
"allowed_paths": list(step.allow_paths),
|
|
73
875
|
"skipped": False,
|
|
74
876
|
}
|