ai-push-hooks 0.1.19 → 0.2.1

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,15 +1,125 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  import pathlib
5
6
  import re
6
7
  import shutil
8
+ import tempfile
7
9
  from dataclasses import dataclass
8
10
  from typing import Any
9
11
 
12
+ from ..paths import (
13
+ ensure_private_directory,
14
+ is_path_within,
15
+ path_has_symlink,
16
+ resolve_contained_path,
17
+ write_text_no_follow,
18
+ )
10
19
  from ..types import HookError, RuntimeContext, StepConfig
11
20
  from .exec import ensure_dir, extract_pr_url, resolve_storage_path, run_command
12
21
 
22
+ OPENCODE_READ_ONLY_AGENT = "ai-push-hooks-readonly"
23
+ OPENCODE_APPLY_AGENT = "ai-push-hooks-apply"
24
+ OPENCODE_AGENT_POLICIES = frozenset({"read-only", "apply"})
25
+ PROVIDER_ENV_PREFIXES = (
26
+ "ANTHROPIC_",
27
+ "AWS_",
28
+ "AZURE_",
29
+ "COHERE_",
30
+ "DEEPSEEK_",
31
+ "GEMINI_",
32
+ "GOOGLE_",
33
+ "GROQ_",
34
+ "MISTRAL_",
35
+ "OPENAI_",
36
+ "OPENROUTER_",
37
+ "VERTEX_",
38
+ "XAI_",
39
+ )
40
+ SAFE_PROCESS_ENV_NAMES = frozenset(
41
+ {
42
+ "PATH",
43
+ "TMPDIR",
44
+ "TMP",
45
+ "TEMP",
46
+ "LANG",
47
+ "LC_ALL",
48
+ "LC_CTYPE",
49
+ "SYSTEMROOT",
50
+ }
51
+ )
52
+
53
+
54
+ def _actual_xdg_data_home() -> pathlib.Path:
55
+ configured = os.environ.get("XDG_DATA_HOME", "").strip()
56
+ if configured:
57
+ return pathlib.Path(configured).expanduser().resolve(strict=False)
58
+ return (pathlib.Path.home() / ".local" / "share").resolve(strict=False)
59
+
60
+
61
+ def opencode_isolation_env(
62
+ context: RuntimeContext,
63
+ security_config: dict[str, Any],
64
+ stage_name: str,
65
+ ) -> dict[str, str | None]:
66
+ lexical_isolation_root = (
67
+ context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
68
+ )
69
+ if path_has_symlink(context.run_dir, lexical_isolation_root):
70
+ raise HookError(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
71
+ isolation_root = resolve_contained_path(
72
+ context.run_dir,
73
+ f"opencode-isolation/{sanitize_filename_component(stage_name)}",
74
+ "OpenCode isolation directory",
75
+ )
76
+ home = isolation_root / "home"
77
+ config_home = isolation_root / "config"
78
+ cache_home = isolation_root / "cache"
79
+ state_home = isolation_root / "state"
80
+ ensure_private_directory(isolation_root)
81
+ for path in (home, config_home, cache_home, state_home):
82
+ ensure_private_directory(path, private_root=isolation_root)
83
+
84
+ isolated: dict[str, str | None] = {
85
+ name: value
86
+ for name, value in os.environ.items()
87
+ if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
88
+ }
89
+ isolated.update({
90
+ "HOME": str(home),
91
+ "XDG_CONFIG_HOME": str(config_home),
92
+ "XDG_CACHE_HOME": str(cache_home),
93
+ "XDG_STATE_HOME": str(state_home),
94
+ "XDG_DATA_HOME": str(_actual_xdg_data_home()),
95
+ "OPENCODE_CONFIG_CONTENT": json.dumps(security_config, ensure_ascii=True),
96
+ "OPENCODE_CONFIG_DIR": str(config_home),
97
+ "OPENCODE_PURE": "true",
98
+ "OPENCODE_DISABLE_PROJECT_CONFIG": "true",
99
+ "OPENCODE_DISABLE_EXTERNAL_SKILLS": "true",
100
+ "OPENCODE_DISABLE_CLAUDE_CODE": "true",
101
+ "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
102
+ "OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
103
+ "OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
104
+ "OPENCODE_DISABLE_SHARE": "true",
105
+ "OPENCODE_DISABLE_AUTOUPDATE": "true",
106
+ })
107
+ return isolated
108
+
109
+
110
+ def non_agent_opencode_config() -> dict[str, Any]:
111
+ return {
112
+ "$schema": "https://opencode.ai/config.json",
113
+ "plugin": [],
114
+ "mcp": {},
115
+ "share": "disabled",
116
+ "instructions": [],
117
+ "formatter": False,
118
+ "lsp": False,
119
+ "command": {},
120
+ "permission": {"*": "deny"},
121
+ }
122
+
13
123
 
14
124
  @dataclass
15
125
  class OpenCodeRunResult:
@@ -135,31 +245,58 @@ def export_opencode_session_json(
135
245
  session_id: str,
136
246
  export_path: pathlib.Path,
137
247
  ) -> bool:
138
- completed = run_command(
139
- [context.opencode_executable or resolve_opencode_executable(), "export", session_id],
140
- cwd=context.repo_root,
141
- timeout=context.config.llm.timeout_seconds,
142
- check=False,
143
- )
248
+ with tempfile.TemporaryDirectory(
249
+ prefix="ai-push-hooks-session-export-"
250
+ ) as temporary_directory:
251
+ completed = run_command(
252
+ [
253
+ context.opencode_executable or resolve_opencode_executable(),
254
+ "export",
255
+ session_id,
256
+ "--pure",
257
+ ],
258
+ cwd=pathlib.Path(temporary_directory).resolve(strict=True),
259
+ timeout=context.config.llm.timeout_seconds,
260
+ check=False,
261
+ env=opencode_isolation_env(context, non_agent_opencode_config(), "session-export"),
262
+ inherit_env=False,
263
+ )
144
264
  if completed.returncode != 0:
145
265
  return False
146
266
  payload = (completed.stdout or "").strip()
147
267
  if not payload:
148
268
  return False
149
- export_path.write_text(payload + "\n", encoding="utf-8")
269
+ write_text_no_follow(export_path, payload + "\n")
150
270
  return True
151
271
 
152
272
 
153
273
  def delete_opencode_session(context: RuntimeContext, session_id: str) -> None:
154
- run_command(
155
- [context.opencode_executable or resolve_opencode_executable(), "session", "delete", session_id],
156
- cwd=context.repo_root,
157
- timeout=context.config.llm.timeout_seconds,
158
- check=False,
159
- )
274
+ with tempfile.TemporaryDirectory(
275
+ prefix="ai-push-hooks-session-delete-"
276
+ ) as temporary_directory:
277
+ run_command(
278
+ [
279
+ context.opencode_executable or resolve_opencode_executable(),
280
+ "session",
281
+ "delete",
282
+ session_id,
283
+ "--pure",
284
+ ],
285
+ cwd=pathlib.Path(temporary_directory).resolve(strict=True),
286
+ timeout=context.config.llm.timeout_seconds,
287
+ check=False,
288
+ env=opencode_isolation_env(context, non_agent_opencode_config(), "session-delete"),
289
+ inherit_env=False,
290
+ )
160
291
 
161
292
 
162
293
  def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_id: str | None) -> None:
294
+ """Capture and finalize a session without retaining it on export failure.
295
+
296
+ Transcript capture is best effort. A failed or interrupted export emits a
297
+ visible warning, then the configured deletion policy still runs so a
298
+ failed capture does not silently retain provider data.
299
+ """
163
300
  if not session_id:
164
301
  return
165
302
  transcript_dir = _transcript_dir(context)
@@ -169,26 +306,192 @@ def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_
169
306
  f"{sanitize_filename_component(stage_name)}-"
170
307
  f"{sanitize_filename_component(session_id)}.json"
171
308
  )
172
- export_opencode_session_json(context, session_id, transcript_dir / export_name)
309
+ export_path = resolve_contained_path(
310
+ transcript_dir,
311
+ export_name,
312
+ "OpenCode transcript path",
313
+ )
314
+ export_failure: str | None = None
315
+ try:
316
+ exported = export_opencode_session_json(context, session_id, export_path)
317
+ except Exception as exc: # noqa: BLE001
318
+ exported = False
319
+ export_failure = type(exc).__name__
320
+ if not exported:
321
+ context.logger.warn(
322
+ "llm.transcript_export_failed",
323
+ "Could not capture the OpenCode transcript; applying configured session deletion.",
324
+ stage_name=stage_name,
325
+ session_id=session_id,
326
+ reason=export_failure or "export returned no transcript",
327
+ )
173
328
  if context.config.llm.delete_session_after_run:
174
329
  delete_opencode_session(context, session_id)
175
330
 
176
331
 
332
+ def build_opencode_security_config(
333
+ agent_policy: str,
334
+ allow_paths: tuple[str, ...] = (),
335
+ *,
336
+ non_vcs_working_directory: pathlib.Path | None = None,
337
+ ) -> tuple[str, dict[str, Any]]:
338
+ permissions: dict[str, Any] = {
339
+ "*": "deny",
340
+ "read": "deny",
341
+ "glob": "deny",
342
+ "grep": "deny",
343
+ "list": "deny",
344
+ "edit": "deny",
345
+ "bash": "deny",
346
+ "task": "deny",
347
+ "external_directory": "deny",
348
+ "webfetch": "deny",
349
+ "websearch": "deny",
350
+ "lsp": "deny",
351
+ "skill": "deny",
352
+ "todowrite": "deny",
353
+ "question": "deny",
354
+ }
355
+ if agent_policy == "read-only":
356
+ agent_name = OPENCODE_READ_ONLY_AGENT
357
+ description = "Read-only ai-push-hooks analysis agent"
358
+ elif agent_policy == "apply":
359
+ if not allow_paths:
360
+ raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
361
+ agent_name = OPENCODE_APPLY_AGENT
362
+ description = "Path-restricted ai-push-hooks apply agent"
363
+ permissions["read"] = "allow"
364
+ permission_patterns: set[str] = set()
365
+ for pattern in allow_paths:
366
+ permission_patterns.add(pattern)
367
+ collapsed = pattern
368
+ while "**/" in collapsed:
369
+ collapsed = collapsed.replace("**/", "", 1)
370
+ permission_patterns.add(collapsed)
371
+ protected_patterns = {".git", ".git/**"}
372
+ if non_vcs_working_directory is not None:
373
+ # OpenCode 1.18.29 assigns `/` as the worktree for a directory
374
+ # without VCS metadata. Its write and edit tools then request the
375
+ # `edit` permission using paths relative to that filesystem root,
376
+ # not relative to the process cwd. Keep the staging checkout free
377
+ # of Git metadata and qualify only its allowlisted paths.
378
+ anchor = pathlib.Path(non_vcs_working_directory.anchor)
379
+ prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
380
+ permission_patterns = {
381
+ f"{prefix}/{pattern}" if prefix else pattern
382
+ for pattern in permission_patterns
383
+ }
384
+ protected_patterns = {
385
+ f"{prefix}/{pattern}" if prefix else pattern
386
+ for pattern in protected_patterns
387
+ }
388
+ edit_permissions = {
389
+ "*": "deny",
390
+ **{pattern: "allow" for pattern in sorted(permission_patterns)},
391
+ }
392
+ for pattern in sorted(protected_patterns):
393
+ edit_permissions[pattern] = "deny"
394
+ permissions["edit"] = edit_permissions
395
+ else:
396
+ raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
397
+
398
+ return agent_name, {
399
+ "$schema": "https://opencode.ai/config.json",
400
+ "plugin": [],
401
+ "mcp": {},
402
+ "share": "disabled",
403
+ "instructions": [],
404
+ "formatter": False,
405
+ "lsp": False,
406
+ "command": {},
407
+ "agent": {
408
+ agent_name: {
409
+ "mode": "primary",
410
+ "description": description,
411
+ "permission": permissions,
412
+ }
413
+ },
414
+ }
415
+
416
+
417
+ def validate_opencode_attachments(
418
+ context: RuntimeContext,
419
+ files: list[pathlib.Path],
420
+ ) -> list[pathlib.Path]:
421
+ run_root = context.run_dir.resolve(strict=True)
422
+ validated: list[pathlib.Path] = []
423
+ for file_path in files:
424
+ lexical_path = pathlib.Path(os.path.abspath(file_path))
425
+ if not is_path_within(lexical_path, run_root):
426
+ raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
427
+ if path_has_symlink(run_root, lexical_path):
428
+ raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
429
+ resolved_path = lexical_path.resolve(strict=True)
430
+ if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
431
+ raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
432
+ validated.append(resolved_path)
433
+ return validated
434
+
435
+
177
436
  def call_opencode(
178
437
  context: RuntimeContext,
179
438
  stage_name: str,
180
439
  purpose: str,
181
440
  prompt: str,
182
441
  files: list[pathlib.Path],
442
+ *,
443
+ agent: str,
444
+ allow_paths: tuple[str, ...] = (),
445
+ working_directory: pathlib.Path | None = None,
183
446
  attempt: int | None = None,
184
447
  total_attempts: int | None = None,
185
448
  existing_session_id: str | None = None,
186
449
  ) -> OpenCodeRunResult:
450
+ """Run OpenCode with a policy-specific isolated working directory.
451
+
452
+ Apply callers must provide the hook-owned, non-VCS staging directory built
453
+ by ``run_apply_step``; this is an internal precondition rather than a
454
+ general repository-working-directory interface.
455
+ """
456
+ if agent not in OPENCODE_AGENT_POLICIES:
457
+ raise HookError(f"Unsupported OpenCode agent policy: {agent}")
458
+ if agent == "apply" and not allow_paths:
459
+ raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
460
+ if agent == "apply" and working_directory is None:
461
+ raise HookError("OpenCode apply agent requires an isolated staging directory")
462
+ if agent == "read-only" and allow_paths:
463
+ raise HookError("OpenCode read-only agent does not accept write paths")
464
+
465
+ validated_files = validate_opencode_attachments(context, files)
466
+ if working_directory is None:
467
+ resolved_working_directory = None
468
+ else:
469
+ try:
470
+ resolved_working_directory = working_directory.resolve(strict=True)
471
+ except (OSError, RuntimeError) as exc:
472
+ raise HookError(
473
+ f"OpenCode working directory must be an existing directory: {working_directory}"
474
+ ) from exc
475
+ if not resolved_working_directory.is_dir():
476
+ raise HookError(
477
+ f"OpenCode working directory must be an existing directory: {working_directory}"
478
+ )
479
+ agent_name, security_config = build_opencode_security_config(
480
+ agent,
481
+ allow_paths,
482
+ non_vcs_working_directory=(
483
+ resolved_working_directory if agent == "apply" else None
484
+ ),
485
+ )
187
486
  executable = context.opencode_executable or resolve_opencode_executable()
188
487
  context.logger.llm_call(stage_name, purpose, context.config.llm.model, attempt, total_attempts)
488
+ isolated_env = opencode_isolation_env(context, security_config, stage_name)
189
489
  cmd = [
190
490
  executable,
191
491
  "run",
492
+ "--agent",
493
+ agent_name,
494
+ "--pure",
192
495
  "--format",
193
496
  "json",
194
497
  "--model",
@@ -200,17 +503,29 @@ def call_opencode(
200
503
  cmd.extend(["--session", existing_session_id])
201
504
  else:
202
505
  cmd.extend(["--title", f"{context.config.llm.session_title_prefix} {context.run_id} {stage_name}"])
203
- for file_path in files:
506
+ for file_path in validated_files:
204
507
  cmd.extend(["--file", str(file_path)])
205
508
  cmd.extend(["--", prompt])
206
509
 
207
- completed = run_command(
208
- cmd,
209
- cwd=context.repo_root,
210
- timeout=context.config.llm.timeout_seconds,
211
- check=False,
212
- env={"OPENCODE_SERVER_PASSWORD": None},
213
- )
510
+ if working_directory is None:
511
+ with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as temporary_directory:
512
+ completed = run_command(
513
+ cmd,
514
+ cwd=pathlib.Path(temporary_directory).resolve(strict=True),
515
+ timeout=context.config.llm.timeout_seconds,
516
+ check=False,
517
+ env=isolated_env,
518
+ inherit_env=False,
519
+ )
520
+ else:
521
+ completed = run_command(
522
+ cmd,
523
+ cwd=resolved_working_directory,
524
+ timeout=context.config.llm.timeout_seconds,
525
+ check=False,
526
+ env=isolated_env,
527
+ inherit_env=False,
528
+ )
214
529
  session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
215
530
  stdout = completed.stdout or ""
216
531
  stderr = completed.stderr or ""
@@ -240,16 +555,21 @@ def run_llm_step(
240
555
  wants_json = bool(step.schema)
241
556
  expects_json_array = step.schema in {"string_array", "docs_issue_array"}
242
557
  for attempt in range(1, total_attempts + 1):
243
- result = call_opencode(
244
- context,
245
- stage_name=stage_name,
246
- purpose=f"{step.type}:{step.id}",
247
- prompt=prompt_text,
248
- files=input_paths,
249
- attempt=attempt,
250
- total_attempts=total_attempts,
251
- existing_session_id=session_id,
252
- )
558
+ try:
559
+ result = call_opencode(
560
+ context,
561
+ stage_name=stage_name,
562
+ purpose=f"{step.type}:{step.id}",
563
+ prompt=prompt_text,
564
+ files=input_paths,
565
+ agent="read-only",
566
+ attempt=attempt,
567
+ total_attempts=total_attempts,
568
+ existing_session_id=session_id,
569
+ )
570
+ except Exception: # noqa: BLE001
571
+ finalize_opencode_session(context, stage_name, session_id)
572
+ raise
253
573
  session_id = result.session_id
254
574
  if result.return_code != 0:
255
575
  finalize_opencode_session(context, stage_name, session_id)
@@ -257,19 +577,22 @@ def run_llm_step(
257
577
  raise HookError(f"OpenCode command failed: {details}")
258
578
  try:
259
579
  if not wants_json:
260
- finalize_opencode_session(context, stage_name, session_id)
261
- return result.output_text
262
- if expects_json_array:
263
- payload = extract_json_array(result.output_text)
580
+ payload = result.output_text
264
581
  else:
265
- payload = extract_json_object(result.output_text)
266
- finalize_opencode_session(context, stage_name, session_id)
267
- return validate_schema(step.schema, payload)
582
+ if expects_json_array:
583
+ payload = extract_json_array(result.output_text)
584
+ else:
585
+ payload = extract_json_object(result.output_text)
586
+ payload = validate_schema(step.schema, payload)
268
587
  except HookError as exc:
269
588
  last_error = str(exc)
270
589
  last_output = result.output_text
271
590
  if attempt >= total_attempts:
272
- break
591
+ finalize_opencode_session(context, stage_name, session_id)
592
+ raise HookError(
593
+ f"Model failed to return valid JSON for {stage_name}: "
594
+ f"{last_error}. {last_output[:400]}"
595
+ ) from exc
273
596
  snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
274
597
  if expects_json_array:
275
598
  suffix = "Return ONLY valid JSON array."
@@ -285,6 +608,7 @@ def run_llm_step(
285
608
  + "\n```"
286
609
  )
287
610
  if context.config.llm.json_retry_new_session:
611
+ finalize_opencode_session(context, stage_name, session_id)
288
612
  session_id = None
289
613
  pr_url = extract_pr_url(last_output)
290
614
  if pr_url:
@@ -294,5 +618,7 @@ def run_llm_step(
294
618
  stage_name=stage_name,
295
619
  url=pr_url,
296
620
  )
297
- finalize_opencode_session(context, stage_name, session_id)
298
- raise HookError(f"Model failed to return valid JSON for {stage_name}: {last_error}. {last_output[:400]}")
621
+ continue
622
+ finalize_opencode_session(context, stage_name, session_id)
623
+ return payload
624
+ raise HookError(f"Model failed to return valid JSON for {stage_name}") # pragma: no cover