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