ai-push-hooks 0.1.18 → 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.
@@ -1,11 +1,773 @@
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 list_repo_changes, path_matches
8
- from .llm import call_opencode, finalize_opencode_session
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
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"
39
+
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
149
+ for path in paths:
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)
218
+ else:
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)
312
+ return snapshot
313
+
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
+ )
9
771
 
10
772
 
11
773
  def run_apply_step(
@@ -16,40 +778,99 @@ def run_apply_step(
16
778
  input_paths: list[pathlib.Path],
17
779
  stage_name: str,
18
780
  ) -> dict[str, object]:
19
- for input_path in input_paths:
781
+ validated_inputs = validate_opencode_attachments(context, input_paths)
782
+ for input_path in validated_inputs:
20
783
  if input_path.name.endswith("issues.json"):
21
784
  issues = json.loads(input_path.read_text(encoding="utf-8"))
22
785
  if isinstance(issues, list) and not issues:
23
786
  return {"changed": False, "changed_files": [], "skipped": True}
24
787
 
788
+ _assert_apply_targets_checked_out_head(context)
789
+ _validate_apply_allowlist(context.repo_root, step.allow_paths)
25
790
  baseline = list_repo_changes(context.repo_root)
26
- files = list(input_paths)
27
- agents = context.repo_root / "AGENTS.md"
28
- if agents.exists():
29
- files.append(agents)
30
-
31
- result = call_opencode(
32
- context,
33
- stage_name=stage_name,
34
- purpose=f"{step.type}:{step.id}",
35
- prompt=prompt,
36
- files=files,
37
- )
38
- finalize_opencode_session(context, stage_name, result.session_id)
39
- if result.return_code != 0:
40
- details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.return_code}"
41
- raise HookError(f"Apply step failed: {details}")
42
-
43
- after = list_repo_changes(context.repo_root)
44
- changed_files = sorted(after - baseline)
45
- unexpected = [
46
- path for path in changed_files if not any(path_matches(path, pattern) for pattern in step.allow_paths)
47
- ]
48
- if unexpected:
49
- raise HookError("Apply step modified files outside allowlist: " + ", ".join(unexpected))
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
50
871
  return {
51
- "changed": bool(changed_files),
52
- "changed_files": changed_files,
872
+ "changed": bool(staged_changes),
873
+ "changed_files": sorted(staged_changes),
53
874
  "allowed_paths": list(step.allow_paths),
54
875
  "skipped": False,
55
876
  }