ai-push-hooks 0.2.1 → 0.3.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.
- package/CHANGELOG.md +73 -1
- package/README.md +80 -525
- package/SECURITY.md +102 -14
- package/ai-push-hooks.toml +9 -2
- package/bin/ai-push-hooks.js +6 -6
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -13
- package/src/ai_push_hooks/config.py +575 -22
- package/src/ai_push_hooks/engine.py +116 -7
- package/src/ai_push_hooks/executors/apply.py +75 -36
- package/src/ai_push_hooks/executors/ask.py +224 -0
- package/src/ai_push_hooks/executors/exec.py +17 -801
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
- package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
- package/src/ai_push_hooks/executors/runners/process.py +464 -0
- package/src/ai_push_hooks/executors/runners/registry.py +117 -0
- package/src/ai_push_hooks/executors/step_commands.py +478 -0
- package/src/ai_push_hooks/git_utils.py +834 -0
- package/src/ai_push_hooks/hook.py +1 -1
- package/src/ai_push_hooks/modules/beads.py +1 -1
- package/src/ai_push_hooks/modules/docs.py +129 -89
- package/src/ai_push_hooks/modules/pr.py +1 -1
- package/src/ai_push_hooks/plugin_loader.py +422 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +407 -75
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
- package/src/ai_push_hooks/executors/llm.py +0 -624
|
@@ -1,624 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import pathlib
|
|
6
|
-
import re
|
|
7
|
-
import shutil
|
|
8
|
-
import tempfile
|
|
9
|
-
from dataclasses import dataclass
|
|
10
|
-
from typing import Any
|
|
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
|
-
)
|
|
19
|
-
from ..types import HookError, RuntimeContext, StepConfig
|
|
20
|
-
from .exec import ensure_dir, extract_pr_url, resolve_storage_path, run_command
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
@dataclass
|
|
125
|
-
class OpenCodeRunResult:
|
|
126
|
-
output_text: str
|
|
127
|
-
session_id: str | None
|
|
128
|
-
stdout: str
|
|
129
|
-
stderr: str
|
|
130
|
-
return_code: int
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def sanitize_filename_component(value: str) -> str:
|
|
134
|
-
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
|
|
135
|
-
return cleaned.strip("-") or "value"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def resolve_opencode_executable() -> str:
|
|
139
|
-
opencode_path = shutil.which("opencode")
|
|
140
|
-
if opencode_path:
|
|
141
|
-
return opencode_path
|
|
142
|
-
cli_path = shutil.which("opencode-cli")
|
|
143
|
-
if cli_path:
|
|
144
|
-
return cli_path
|
|
145
|
-
raise HookError("opencode is required but not installed")
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def parse_opencode_json_run_output(raw: str) -> tuple[str | None, str]:
|
|
149
|
-
session_id: str | None = None
|
|
150
|
-
parts: list[str] = []
|
|
151
|
-
for line in raw.splitlines():
|
|
152
|
-
payload = line.strip()
|
|
153
|
-
if not payload:
|
|
154
|
-
continue
|
|
155
|
-
try:
|
|
156
|
-
event = json.loads(payload)
|
|
157
|
-
except json.JSONDecodeError:
|
|
158
|
-
continue
|
|
159
|
-
if not isinstance(event, dict):
|
|
160
|
-
continue
|
|
161
|
-
if session_id is None and isinstance(event.get("sessionID"), str):
|
|
162
|
-
session_id = str(event["sessionID"]).strip()
|
|
163
|
-
if event.get("type") != "text":
|
|
164
|
-
continue
|
|
165
|
-
part = event.get("part")
|
|
166
|
-
if isinstance(part, dict) and isinstance(part.get("text"), str) and part["text"].strip():
|
|
167
|
-
parts.append(part["text"])
|
|
168
|
-
return session_id, "\n".join(parts).strip()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def extract_json_array(text: str) -> list[Any]:
|
|
172
|
-
start = text.find("[")
|
|
173
|
-
end = text.rfind("]")
|
|
174
|
-
if start < 0 or end < start:
|
|
175
|
-
raise HookError("Could not find JSON array in model output")
|
|
176
|
-
try:
|
|
177
|
-
payload = json.loads(text[start : end + 1])
|
|
178
|
-
except json.JSONDecodeError as exc:
|
|
179
|
-
raise HookError(f"Failed to parse JSON array from model output: {exc}") from exc
|
|
180
|
-
if not isinstance(payload, list):
|
|
181
|
-
raise HookError("Model output JSON is not an array")
|
|
182
|
-
return payload
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def extract_json_object(text: str) -> dict[str, Any]:
|
|
186
|
-
start = text.find("{")
|
|
187
|
-
end = text.rfind("}")
|
|
188
|
-
if start < 0 or end < start:
|
|
189
|
-
raise HookError("Could not find JSON object in model output")
|
|
190
|
-
try:
|
|
191
|
-
payload = json.loads(text[start : end + 1])
|
|
192
|
-
except json.JSONDecodeError as exc:
|
|
193
|
-
raise HookError(f"Failed to parse JSON object from model output: {exc}") from exc
|
|
194
|
-
if not isinstance(payload, dict):
|
|
195
|
-
raise HookError("Model output JSON is not an object")
|
|
196
|
-
return payload
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
200
|
-
if schema is None:
|
|
201
|
-
return payload
|
|
202
|
-
if schema == "string_array":
|
|
203
|
-
if not isinstance(payload, list) or not all(isinstance(item, str) for item in payload):
|
|
204
|
-
raise HookError("Expected schema string_array")
|
|
205
|
-
return payload
|
|
206
|
-
if schema == "docs_issue_array":
|
|
207
|
-
if not isinstance(payload, list):
|
|
208
|
-
raise HookError("Expected schema docs_issue_array")
|
|
209
|
-
for item in payload:
|
|
210
|
-
if not isinstance(item, dict):
|
|
211
|
-
raise HookError("docs_issue_array items must be objects")
|
|
212
|
-
if not str(item.get("file", "")).strip() or not str(item.get("description", "")).strip():
|
|
213
|
-
raise HookError("docs_issue_array items require file and description")
|
|
214
|
-
return payload
|
|
215
|
-
if schema == "beads_alignment_result":
|
|
216
|
-
if not isinstance(payload, dict):
|
|
217
|
-
raise HookError("Expected schema beads_alignment_result")
|
|
218
|
-
commands = payload.get("commands", [])
|
|
219
|
-
if commands is not None and (
|
|
220
|
-
not isinstance(commands, list) or not all(isinstance(item, str) for item in commands)
|
|
221
|
-
):
|
|
222
|
-
raise HookError("beads_alignment_result.commands must be an array of strings")
|
|
223
|
-
return payload
|
|
224
|
-
if schema == "pr_create_payload":
|
|
225
|
-
if not isinstance(payload, dict):
|
|
226
|
-
raise HookError("Expected schema pr_create_payload")
|
|
227
|
-
return payload
|
|
228
|
-
raise HookError(f"Unsupported schema: {schema}")
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _transcript_dir(context: RuntimeContext) -> pathlib.Path | None:
|
|
232
|
-
if not context.config.logging.capture_llm_transcript:
|
|
233
|
-
return None
|
|
234
|
-
return ensure_dir(
|
|
235
|
-
resolve_storage_path(
|
|
236
|
-
context.repo_root,
|
|
237
|
-
context.git_dir,
|
|
238
|
-
context.config.logging.transcript_dir,
|
|
239
|
-
)
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def export_opencode_session_json(
|
|
244
|
-
context: RuntimeContext,
|
|
245
|
-
session_id: str,
|
|
246
|
-
export_path: pathlib.Path,
|
|
247
|
-
) -> bool:
|
|
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
|
-
)
|
|
264
|
-
if completed.returncode != 0:
|
|
265
|
-
return False
|
|
266
|
-
payload = (completed.stdout or "").strip()
|
|
267
|
-
if not payload:
|
|
268
|
-
return False
|
|
269
|
-
write_text_no_follow(export_path, payload + "\n")
|
|
270
|
-
return True
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def delete_opencode_session(context: RuntimeContext, session_id: str) -> None:
|
|
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
|
-
)
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
"""
|
|
300
|
-
if not session_id:
|
|
301
|
-
return
|
|
302
|
-
transcript_dir = _transcript_dir(context)
|
|
303
|
-
if transcript_dir is not None:
|
|
304
|
-
export_name = (
|
|
305
|
-
f"{sanitize_filename_component(context.run_id)}-"
|
|
306
|
-
f"{sanitize_filename_component(stage_name)}-"
|
|
307
|
-
f"{sanitize_filename_component(session_id)}.json"
|
|
308
|
-
)
|
|
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
|
-
)
|
|
328
|
-
if context.config.llm.delete_session_after_run:
|
|
329
|
-
delete_opencode_session(context, session_id)
|
|
330
|
-
|
|
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
|
-
|
|
436
|
-
def call_opencode(
|
|
437
|
-
context: RuntimeContext,
|
|
438
|
-
stage_name: str,
|
|
439
|
-
purpose: str,
|
|
440
|
-
prompt: str,
|
|
441
|
-
files: list[pathlib.Path],
|
|
442
|
-
*,
|
|
443
|
-
agent: str,
|
|
444
|
-
allow_paths: tuple[str, ...] = (),
|
|
445
|
-
working_directory: pathlib.Path | None = None,
|
|
446
|
-
attempt: int | None = None,
|
|
447
|
-
total_attempts: int | None = None,
|
|
448
|
-
existing_session_id: str | None = None,
|
|
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
|
-
)
|
|
486
|
-
executable = context.opencode_executable or resolve_opencode_executable()
|
|
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)
|
|
489
|
-
cmd = [
|
|
490
|
-
executable,
|
|
491
|
-
"run",
|
|
492
|
-
"--agent",
|
|
493
|
-
agent_name,
|
|
494
|
-
"--pure",
|
|
495
|
-
"--format",
|
|
496
|
-
"json",
|
|
497
|
-
"--model",
|
|
498
|
-
context.config.llm.model,
|
|
499
|
-
]
|
|
500
|
-
if context.config.llm.variant:
|
|
501
|
-
cmd.extend(["--variant", context.config.llm.variant])
|
|
502
|
-
if existing_session_id:
|
|
503
|
-
cmd.extend(["--session", existing_session_id])
|
|
504
|
-
else:
|
|
505
|
-
cmd.extend(["--title", f"{context.config.llm.session_title_prefix} {context.run_id} {stage_name}"])
|
|
506
|
-
for file_path in validated_files:
|
|
507
|
-
cmd.extend(["--file", str(file_path)])
|
|
508
|
-
cmd.extend(["--", prompt])
|
|
509
|
-
|
|
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
|
-
)
|
|
529
|
-
session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
|
|
530
|
-
stdout = completed.stdout or ""
|
|
531
|
-
stderr = completed.stderr or ""
|
|
532
|
-
if context.config.logging.print_llm_output and stdout.strip():
|
|
533
|
-
print(stdout)
|
|
534
|
-
return OpenCodeRunResult(
|
|
535
|
-
output_text=text_output if text_output else stdout.strip(),
|
|
536
|
-
session_id=session_id or existing_session_id,
|
|
537
|
-
stdout=stdout,
|
|
538
|
-
stderr=stderr,
|
|
539
|
-
return_code=completed.returncode,
|
|
540
|
-
)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def run_llm_step(
|
|
544
|
-
context: RuntimeContext,
|
|
545
|
-
step: StepConfig,
|
|
546
|
-
prompt: str,
|
|
547
|
-
input_paths: list[pathlib.Path],
|
|
548
|
-
stage_name: str,
|
|
549
|
-
) -> Any:
|
|
550
|
-
total_attempts = context.config.llm.json_max_retries + 1
|
|
551
|
-
session_id: str | None = None
|
|
552
|
-
prompt_text = prompt
|
|
553
|
-
last_error = ""
|
|
554
|
-
last_output = ""
|
|
555
|
-
wants_json = bool(step.schema)
|
|
556
|
-
expects_json_array = step.schema in {"string_array", "docs_issue_array"}
|
|
557
|
-
for attempt in range(1, total_attempts + 1):
|
|
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
|
|
573
|
-
session_id = result.session_id
|
|
574
|
-
if result.return_code != 0:
|
|
575
|
-
finalize_opencode_session(context, stage_name, session_id)
|
|
576
|
-
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.return_code}"
|
|
577
|
-
raise HookError(f"OpenCode command failed: {details}")
|
|
578
|
-
try:
|
|
579
|
-
if not wants_json:
|
|
580
|
-
payload = result.output_text
|
|
581
|
-
else:
|
|
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)
|
|
587
|
-
except HookError as exc:
|
|
588
|
-
last_error = str(exc)
|
|
589
|
-
last_output = result.output_text
|
|
590
|
-
if attempt >= total_attempts:
|
|
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
|
|
596
|
-
snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
|
|
597
|
-
if expects_json_array:
|
|
598
|
-
suffix = "Return ONLY valid JSON array."
|
|
599
|
-
else:
|
|
600
|
-
suffix = "Return ONLY valid JSON object."
|
|
601
|
-
prompt_text = (
|
|
602
|
-
prompt
|
|
603
|
-
+ "\n\nIMPORTANT: Your previous response was invalid JSON and could not be parsed.\n"
|
|
604
|
-
+ f"Parse error: {last_error}\n"
|
|
605
|
-
+ suffix
|
|
606
|
-
+ "\nPrevious invalid output:\n```text\n"
|
|
607
|
-
+ snippet
|
|
608
|
-
+ "\n```"
|
|
609
|
-
)
|
|
610
|
-
if context.config.llm.json_retry_new_session:
|
|
611
|
-
finalize_opencode_session(context, stage_name, session_id)
|
|
612
|
-
session_id = None
|
|
613
|
-
pr_url = extract_pr_url(last_output)
|
|
614
|
-
if pr_url:
|
|
615
|
-
context.logger.info(
|
|
616
|
-
"llm.invalid_json_pr_url_hint",
|
|
617
|
-
"Detected PR URL in invalid JSON output",
|
|
618
|
-
stage_name=stage_name,
|
|
619
|
-
url=pr_url,
|
|
620
|
-
)
|
|
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
|