ai-push-hooks 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +80 -993
  3. package/SECURITY.md +21 -16
  4. package/ai-push-hooks.toml +1 -1
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/docs/configuration.md +263 -0
  7. package/package.json +4 -3
  8. package/pyproject.toml +12 -3
  9. package/src/ai_push_hooks/artifacts.py +19 -19
  10. package/src/ai_push_hooks/cli.py +24 -8
  11. package/src/ai_push_hooks/config.py +239 -54
  12. package/src/ai_push_hooks/engine.py +94 -43
  13. package/src/ai_push_hooks/executors/apply.py +124 -46
  14. package/src/ai_push_hooks/executors/ask.py +30 -536
  15. package/src/ai_push_hooks/executors/exec.py +73 -818
  16. package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
  17. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  18. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  19. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  20. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  21. package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
  22. package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
  23. package/src/ai_push_hooks/executors/runners/process.py +97 -17
  24. package/src/ai_push_hooks/executors/runners/registry.py +31 -9
  25. package/src/ai_push_hooks/executors/step_commands.py +65 -20
  26. package/src/ai_push_hooks/git_utils.py +899 -0
  27. package/src/ai_push_hooks/hook.py +49 -13
  28. package/src/ai_push_hooks/install.py +40 -18
  29. package/src/ai_push_hooks/modules/beads.py +19 -8
  30. package/src/ai_push_hooks/modules/docs.py +146 -96
  31. package/src/ai_push_hooks/modules/pr.py +19 -8
  32. package/src/ai_push_hooks/paths.py +6 -2
  33. package/src/ai_push_hooks/plugin_loader.py +182 -105
  34. package/src/ai_push_hooks/plugins.py +3 -1
  35. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  36. package/src/ai_push_hooks/types.py +48 -27
  37. package/vendor/README.md +15 -0
  38. package/vendor/requirements.txt +1 -0
  39. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  40. package/run.sh +0 -29
@@ -1,8 +1,8 @@
1
1
  """OpenCode adapter for the internal runner boundary.
2
2
 
3
- The compatibility invocation deliberately remains the shipped ``opencode
4
- run`` shape. This module owns the adapter-level protocol and lifecycle
5
- semantics; workflow retries and JSON/schema handling remain in orchestration.
3
+ The adapter deliberately retains the shipped ``opencode run`` shape. This
4
+ module owns the adapter-level protocol and lifecycle semantics; workflow
5
+ retries and JSON/schema handling remain in orchestration.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -14,7 +14,7 @@ import sys
14
14
  import tempfile
15
15
  from typing import Any
16
16
 
17
- from ..ask import (
17
+ from .opencode_support import (
18
18
  OPENCODE_APPLY_AGENT,
19
19
  OPENCODE_READ_ONLY_AGENT,
20
20
  _transcript_dir,
@@ -23,10 +23,13 @@ from ..ask import (
23
23
  opencode_isolation_env,
24
24
  resolve_opencode_executable,
25
25
  sanitize_filename_component,
26
- validate_opencode_attachments,
26
+ validate_hook_owned_artifacts,
27
+ )
28
+ from ...paths import (
29
+ ensure_private_directory,
30
+ resolve_contained_path,
31
+ write_text_no_follow,
27
32
  )
28
- from ...paths import resolve_contained_path, write_text_no_follow
29
- from ...paths import ensure_private_directory
30
33
  from .process import ProcessResult
31
34
  from .contracts import (
32
35
  RunnerCapabilities,
@@ -66,7 +69,9 @@ class OpenCodeRunner:
66
69
  session_id: str | None = None
67
70
  text_parts: list[str] = []
68
71
 
69
- def protocol_failure(message: str, *, missing: bool = False) -> RunnerProtocolError:
72
+ def protocol_failure(
73
+ message: str, *, missing: bool = False
74
+ ) -> RunnerProtocolError:
70
75
  error: RunnerProtocolError
71
76
  if missing:
72
77
  error = RunnerMissingOutputError(message)
@@ -87,7 +92,9 @@ class OpenCodeRunner:
87
92
  try:
88
93
  event = json.loads(line)
89
94
  except json.JSONDecodeError as exc:
90
- raise protocol_failure("OpenCode emitted malformed JSONL output") from exc
95
+ raise protocol_failure(
96
+ "OpenCode emitted malformed JSONL output"
97
+ ) from exc
91
98
  if not isinstance(event, dict):
92
99
  raise protocol_failure("OpenCode emitted a non-object JSONL event")
93
100
 
@@ -159,7 +166,7 @@ class OpenCodeRunner:
159
166
  # Validate source ownership and symlink traversal before making any
160
167
  # copy. The source is then never reopened: its logical snapshot is
161
168
  # the only content sent through OpenCode's native --file transport.
162
- validate_opencode_attachments(
169
+ validate_hook_owned_artifacts(
163
170
  context,
164
171
  [path for path in original_paths if path is not None],
165
172
  )
@@ -221,7 +228,11 @@ class OpenCodeRunner:
221
228
  *,
222
229
  prompt: str,
223
230
  ) -> list[str]:
224
- agent = OPENCODE_APPLY_AGENT if request.mode == "apply" else OPENCODE_READ_ONLY_AGENT
231
+ agent = (
232
+ OPENCODE_APPLY_AGENT
233
+ if request.mode == "apply"
234
+ else OPENCODE_READ_ONLY_AGENT
235
+ )
225
236
  config = getattr(getattr(context, "config", None), "llm", None)
226
237
  title_prefix = getattr(config, "session_title_prefix", "ai-push-hooks")
227
238
  argv = [
@@ -251,6 +262,34 @@ class OpenCodeRunner:
251
262
  argv.extend(["--", prompt])
252
263
  return argv
253
264
 
265
+ @staticmethod
266
+ def _require_staging_directory(
267
+ context: Any, working_directory: pathlib.Path
268
+ ) -> None:
269
+ """Reject repository worktrees as apply targets.
270
+
271
+ The apply workflow supplies a disposable, non-VCS staging projection.
272
+ A runner request can identify the repository root and the selected cwd,
273
+ but it cannot turn an arbitrary host directory into a sandbox. Refuse
274
+ the known unsafe cases at this boundary instead of granting edit
275
+ permissions to the checked-out repository.
276
+ """
277
+
278
+ repository_root = context.repo_root.resolve(strict=True)
279
+ if working_directory == repository_root or working_directory.is_relative_to(
280
+ repository_root
281
+ ):
282
+ raise RunnerContractError(
283
+ "OpenCode apply requests require an isolated staging directory"
284
+ )
285
+ if any(
286
+ (parent / ".git").exists()
287
+ for parent in (working_directory, *working_directory.parents)
288
+ ):
289
+ raise RunnerContractError(
290
+ "OpenCode apply requests require a non-VCS isolated staging directory"
291
+ )
292
+
254
293
  def run(self, request: RunnerRequest) -> RunnerResult:
255
294
  if request.runner_type != "opencode":
256
295
  raise RunnerContractError(
@@ -268,8 +307,19 @@ class OpenCodeRunner:
268
307
  else:
269
308
  working_directory = None
270
309
 
271
- if working_directory is not None and not working_directory.is_dir():
272
- raise RunnerContractError("OpenCode request cwd must be an existing directory")
310
+ if working_directory is not None:
311
+ try:
312
+ working_directory = working_directory.resolve(strict=True)
313
+ except (OSError, RuntimeError) as exc:
314
+ raise RunnerContractError(
315
+ "OpenCode request cwd must be an existing directory"
316
+ ) from exc
317
+ if not working_directory.is_dir():
318
+ raise RunnerContractError(
319
+ "OpenCode request cwd must be an existing directory"
320
+ )
321
+ if request.mode == "apply":
322
+ self._require_staging_directory(context, working_directory)
273
323
 
274
324
  agent = "apply" if request.mode == "apply" else "read-only"
275
325
  _agent_name, security_config = build_opencode_security_config(
@@ -282,12 +332,17 @@ class OpenCodeRunner:
282
332
  working_directory if request.project_access == "project" else None
283
333
  ),
284
334
  )
285
- executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
335
+ executable = (
336
+ getattr(context, "opencode_executable", None)
337
+ or resolve_opencode_executable()
338
+ )
286
339
  isolated_env = opencode_isolation_env(context, security_config, request.stage)
287
340
  attachment_dir: pathlib.Path | None = None
288
341
  session_id: str | None = None
289
342
  try:
290
- attachments, attachment_dir = self._materialize_attachments(context, request)
343
+ attachments, attachment_dir = self._materialize_attachments(
344
+ context, request
345
+ )
291
346
  # Every logical artifact is materialized, including pathless
292
347
  # contract artifacts. Therefore native attachments carry the
293
348
  # complete snapshot and the instruction is never duplicated in a
@@ -322,14 +377,18 @@ class OpenCodeRunner:
322
377
 
323
378
  try:
324
379
  if working_directory is None:
325
- with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as directory:
380
+ with tempfile.TemporaryDirectory(
381
+ prefix="ai-push-hooks-readonly-"
382
+ ) as directory:
326
383
  completed = invoke(pathlib.Path(directory).resolve(strict=True))
327
384
  else:
328
- completed = invoke(working_directory.resolve(strict=True))
385
+ completed = invoke(working_directory)
329
386
  except (RunnerError,) as error:
330
387
  process_result = getattr(error, "_process_result", None)
331
388
  partial_stdout = (
332
- process_result.stdout if isinstance(process_result, ProcessResult) else ""
389
+ process_result.stdout
390
+ if isinstance(process_result, ProcessResult)
391
+ else ""
333
392
  )
334
393
  setattr(
335
394
  error,
@@ -368,7 +427,9 @@ class OpenCodeRunner:
368
427
  stdout=completed.stdout,
369
428
  stderr=completed.stderr,
370
429
  session=(
371
- SessionMetadata(session_id=session_id, state="persisted", resumable=True)
430
+ SessionMetadata(
431
+ session_id=session_id, state="persisted", resumable=True
432
+ )
372
433
  if session_id
373
434
  else SessionMetadata()
374
435
  ),
@@ -398,7 +459,9 @@ class OpenCodeRunner:
398
459
  non_agent_opencode_config(),
399
460
  request.stage,
400
461
  )
401
- with tempfile.TemporaryDirectory(prefix=f"ai-push-hooks-session-{action}-") as directory:
462
+ with tempfile.TemporaryDirectory(
463
+ prefix=f"ai-push-hooks-session-{action}-"
464
+ ) as directory:
402
465
  return run_process(
403
466
  argv,
404
467
  cwd=pathlib.Path(directory).resolve(strict=True),
@@ -427,7 +490,10 @@ class OpenCodeRunner:
427
490
  "OpenCode transcript path",
428
491
  )
429
492
  try:
430
- executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
493
+ executable = (
494
+ getattr(context, "opencode_executable", None)
495
+ or resolve_opencode_executable()
496
+ )
431
497
  exported = self._lifecycle_process(
432
498
  request,
433
499
  context,
@@ -440,10 +506,17 @@ class OpenCodeRunner:
440
506
  and not getattr(exported, "stderr_truncated", False)
441
507
  and exported.stdout.strip()
442
508
  ):
443
- write_text_no_follow(transcript_path, exported.stdout.strip() + "\n")
509
+ write_text_no_follow(
510
+ transcript_path, exported.stdout.strip() + "\n"
511
+ )
444
512
  else:
445
513
  transcript_path = None
446
- self._warn_export(context, request, session_id, "export returned no transcript")
514
+ self._warn_export(
515
+ context,
516
+ request,
517
+ session_id,
518
+ "export returned no transcript",
519
+ )
447
520
  except Exception as exc: # noqa: BLE001
448
521
  transcript_path = None
449
522
  self._warn_export(context, request, session_id, type(exc).__name__)
@@ -451,7 +524,10 @@ class OpenCodeRunner:
451
524
  deleted = False
452
525
  if getattr(context.config.llm, "delete_session_after_run", False):
453
526
  try:
454
- executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
527
+ executable = (
528
+ getattr(context, "opencode_executable", None)
529
+ or resolve_opencode_executable()
530
+ )
455
531
  deleted_result = self._lifecycle_process(
456
532
  request,
457
533
  context,
@@ -460,9 +536,13 @@ class OpenCodeRunner:
460
536
  )
461
537
  deleted = deleted_result.returncode == 0
462
538
  if not deleted:
463
- self._warn_export(context, request, session_id, "session deletion failed")
539
+ self._warn_export(
540
+ context, request, session_id, "session deletion failed"
541
+ )
464
542
  except Exception as exc: # noqa: BLE001
465
- self._warn_export(context, request, session_id, f"delete {type(exc).__name__}")
543
+ self._warn_export(
544
+ context, request, session_id, f"delete {type(exc).__name__}"
545
+ )
466
546
 
467
547
  state = "deleted" if deleted else "persisted"
468
548
  finalized_session = SessionMetadata(
@@ -481,7 +561,9 @@ class OpenCodeRunner:
481
561
  )
482
562
 
483
563
  @staticmethod
484
- def _warn_export(context: Any, request: RunnerRequest, session_id: str, reason: str) -> None:
564
+ def _warn_export(
565
+ context: Any, request: RunnerRequest, session_id: str, reason: str
566
+ ) -> None:
485
567
  logger = getattr(context, "logger", None)
486
568
  if logger is not None:
487
569
  logger.warn(
@@ -0,0 +1,286 @@
1
+ """Shared OpenCode isolation and hook-owned artifact policy helpers.
2
+
3
+ This module contains only the OpenCode-specific filesystem, environment, and
4
+ permission policy used by the active adapter. Workflow orchestration remains
5
+ runner-neutral and the adapter owns invocation and session lifecycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import re
14
+ import shutil
15
+ from typing import Any
16
+
17
+ from ...paths import (
18
+ ensure_private_directory,
19
+ is_path_within,
20
+ path_has_symlink,
21
+ resolve_contained_path,
22
+ )
23
+ from ...git_utils import ensure_dir, resolve_storage_path
24
+ from ...types import HookError, RuntimeContext
25
+
26
+
27
+ OPENCODE_READ_ONLY_AGENT = "ai-push-hooks-readonly"
28
+ OPENCODE_APPLY_AGENT = "ai-push-hooks-apply"
29
+ PROVIDER_ENV_PREFIXES = (
30
+ "ANTHROPIC_",
31
+ "AWS_",
32
+ "AZURE_",
33
+ "COHERE_",
34
+ "DEEPSEEK_",
35
+ "GEMINI_",
36
+ "GOOGLE_",
37
+ "GROQ_",
38
+ "MISTRAL_",
39
+ "OPENAI_",
40
+ "OPENROUTER_",
41
+ "VERTEX_",
42
+ "XAI_",
43
+ )
44
+ SAFE_PROCESS_ENV_NAMES = frozenset(
45
+ {
46
+ "PATH",
47
+ "TMPDIR",
48
+ "TMP",
49
+ "TEMP",
50
+ "LANG",
51
+ "LC_ALL",
52
+ "LC_CTYPE",
53
+ "SYSTEMROOT",
54
+ }
55
+ )
56
+
57
+
58
+ def _actual_xdg_data_home() -> pathlib.Path:
59
+ configured = os.environ.get("XDG_DATA_HOME", "").strip()
60
+ if configured:
61
+ return pathlib.Path(configured).expanduser().resolve(strict=False)
62
+ return (pathlib.Path.home() / ".local" / "share").resolve(strict=False)
63
+
64
+
65
+ def opencode_isolation_env(
66
+ context: RuntimeContext,
67
+ security_config: dict[str, Any],
68
+ stage_name: str,
69
+ ) -> dict[str, str | None]:
70
+ lexical_isolation_root = (
71
+ context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
72
+ )
73
+ if path_has_symlink(context.run_dir, lexical_isolation_root):
74
+ raise HookError(
75
+ f"OpenCode isolation directory must not traverse a symlink: {stage_name}"
76
+ )
77
+ isolation_root = resolve_contained_path(
78
+ context.run_dir,
79
+ f"opencode-isolation/{sanitize_filename_component(stage_name)}",
80
+ "OpenCode isolation directory",
81
+ )
82
+ home = isolation_root / "home"
83
+ config_home = isolation_root / "config"
84
+ cache_home = isolation_root / "cache"
85
+ state_home = isolation_root / "state"
86
+ ensure_private_directory(isolation_root)
87
+ for path in (home, config_home, cache_home, state_home):
88
+ ensure_private_directory(path, private_root=isolation_root)
89
+
90
+ isolated: dict[str, str | None] = {
91
+ name: value
92
+ for name, value in os.environ.items()
93
+ if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
94
+ }
95
+ isolated.update(
96
+ {
97
+ "HOME": str(home),
98
+ "XDG_CONFIG_HOME": str(config_home),
99
+ "XDG_CACHE_HOME": str(cache_home),
100
+ "XDG_STATE_HOME": str(state_home),
101
+ "XDG_DATA_HOME": str(_actual_xdg_data_home()),
102
+ "OPENCODE_CONFIG_CONTENT": json.dumps(security_config, ensure_ascii=True),
103
+ "OPENCODE_CONFIG_DIR": str(config_home),
104
+ "OPENCODE_PURE": "true",
105
+ "OPENCODE_DISABLE_PROJECT_CONFIG": "true",
106
+ "OPENCODE_DISABLE_EXTERNAL_SKILLS": "true",
107
+ "OPENCODE_DISABLE_CLAUDE_CODE": "true",
108
+ "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
109
+ "OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
110
+ "OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
111
+ "OPENCODE_DISABLE_SHARE": "true",
112
+ "OPENCODE_DISABLE_AUTOUPDATE": "true",
113
+ }
114
+ )
115
+ return isolated
116
+
117
+
118
+ def non_agent_opencode_config() -> dict[str, Any]:
119
+ return {
120
+ "$schema": "https://opencode.ai/config.json",
121
+ "plugin": [],
122
+ "mcp": {},
123
+ "share": "disabled",
124
+ "instructions": [],
125
+ "formatter": False,
126
+ "lsp": False,
127
+ "command": {},
128
+ "permission": {"*": "deny"},
129
+ }
130
+
131
+
132
+ def sanitize_filename_component(value: str) -> str:
133
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
134
+ return cleaned.strip("-") or "value"
135
+
136
+
137
+ def resolve_opencode_executable() -> str:
138
+ opencode_path = shutil.which("opencode")
139
+ if opencode_path:
140
+ return opencode_path
141
+ cli_path = shutil.which("opencode-cli")
142
+ if cli_path:
143
+ return cli_path
144
+ raise HookError("opencode is required but not installed")
145
+
146
+
147
+ def _transcript_dir(context: RuntimeContext) -> pathlib.Path | None:
148
+ if not context.config.logging.capture_llm_transcript:
149
+ return None
150
+ return ensure_dir(
151
+ resolve_storage_path(
152
+ context.repo_root,
153
+ context.git_dir,
154
+ context.config.logging.transcript_dir,
155
+ )
156
+ )
157
+
158
+
159
+ def build_opencode_security_config(
160
+ agent_policy: str,
161
+ allow_paths: tuple[str, ...] = (),
162
+ *,
163
+ non_vcs_working_directory: pathlib.Path | None = None,
164
+ project_read_root: pathlib.Path | None = None,
165
+ ) -> tuple[str, dict[str, Any]]:
166
+ permissions: dict[str, Any] = {
167
+ "*": "deny",
168
+ "read": "deny",
169
+ "glob": "deny",
170
+ "grep": "deny",
171
+ "list": "deny",
172
+ "edit": "deny",
173
+ "bash": "deny",
174
+ "task": "deny",
175
+ "external_directory": "deny",
176
+ "webfetch": "deny",
177
+ "websearch": "deny",
178
+ "lsp": "deny",
179
+ "skill": "deny",
180
+ "todowrite": "deny",
181
+ "question": "deny",
182
+ "doom_loop": "deny",
183
+ }
184
+ if agent_policy == "read-only":
185
+ agent_name = OPENCODE_READ_ONLY_AGENT
186
+ description = "Read-only ai-push-hooks analysis agent"
187
+ elif agent_policy == "apply":
188
+ if not allow_paths:
189
+ raise HookError(
190
+ "OpenCode apply agent requires an explicit non-empty allow_paths"
191
+ )
192
+ agent_name = OPENCODE_APPLY_AGENT
193
+ description = "Path-restricted ai-push-hooks apply agent"
194
+ permissions["read"] = "allow"
195
+ permission_patterns: set[str] = set()
196
+ for pattern in allow_paths:
197
+ permission_patterns.add(pattern)
198
+ collapsed = pattern
199
+ while "**/" in collapsed:
200
+ collapsed = collapsed.replace("**/", "", 1)
201
+ permission_patterns.add(collapsed)
202
+ protected_patterns = {".git", ".git/**"}
203
+ if non_vcs_working_directory is not None:
204
+ # OpenCode assigns `/` as the worktree for a directory without VCS
205
+ # metadata, so qualify allowlisted paths with that filesystem root.
206
+ anchor = pathlib.Path(non_vcs_working_directory.anchor)
207
+ prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
208
+ permission_patterns = {
209
+ f"{prefix}/{pattern}" if prefix else pattern
210
+ for pattern in permission_patterns
211
+ }
212
+ protected_patterns = {
213
+ f"{prefix}/{pattern}" if prefix else pattern
214
+ for pattern in protected_patterns
215
+ }
216
+ edit_permissions = {
217
+ "*": "deny",
218
+ **{pattern: "allow" for pattern in sorted(permission_patterns)},
219
+ }
220
+ for pattern in sorted(protected_patterns):
221
+ edit_permissions[pattern] = "deny"
222
+ permissions["edit"] = edit_permissions
223
+ else:
224
+ raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
225
+
226
+ if project_read_root is not None:
227
+ root = project_read_root.resolve(strict=False)
228
+ if (root / ".git").exists():
229
+ rooted_permissions: str | dict[str, str] = "allow"
230
+ else:
231
+ anchor = pathlib.Path(root.anchor)
232
+ prefix = root.relative_to(anchor).as_posix()
233
+ rooted_permissions = {
234
+ "*": "deny",
235
+ prefix: "allow",
236
+ f"{prefix}/**": "allow",
237
+ }
238
+ for tool in ("read", "list", "glob", "grep"):
239
+ permissions[tool] = (
240
+ dict(rooted_permissions)
241
+ if isinstance(rooted_permissions, dict)
242
+ else rooted_permissions
243
+ )
244
+
245
+ return agent_name, {
246
+ "$schema": "https://opencode.ai/config.json",
247
+ "plugin": [],
248
+ "mcp": {},
249
+ "share": "disabled",
250
+ "instructions": [],
251
+ "formatter": False,
252
+ "lsp": False,
253
+ "command": {},
254
+ "agent": {
255
+ agent_name: {
256
+ "mode": "primary",
257
+ "description": description,
258
+ "permission": permissions,
259
+ }
260
+ },
261
+ }
262
+
263
+
264
+ def validate_hook_owned_artifacts(
265
+ context: RuntimeContext,
266
+ files: list[pathlib.Path],
267
+ ) -> list[pathlib.Path]:
268
+ run_root = context.run_dir.resolve(strict=True)
269
+ validated: list[pathlib.Path] = []
270
+ for file_path in files:
271
+ lexical_path = pathlib.Path(os.path.abspath(file_path))
272
+ if not is_path_within(lexical_path, run_root):
273
+ raise HookError(
274
+ f"OpenCode attachment is not a hook-owned artifact: {file_path}"
275
+ )
276
+ if path_has_symlink(run_root, lexical_path):
277
+ raise HookError(
278
+ f"OpenCode attachment must not traverse a symlink: {file_path}"
279
+ )
280
+ resolved_path = lexical_path.resolve(strict=True)
281
+ if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
282
+ raise HookError(
283
+ f"OpenCode attachment must be a regular hook-owned file: {file_path}"
284
+ )
285
+ validated.append(resolved_path)
286
+ return validated