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.
- package/CHANGELOG.md +78 -2
- package/README.md +80 -993
- package/SECURITY.md +21 -16
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +6 -6
- package/docs/configuration.md +263 -0
- package/package.json +4 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -19
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +239 -54
- package/src/ai_push_hooks/engine.py +94 -43
- package/src/ai_push_hooks/executors/apply.py +124 -46
- package/src/ai_push_hooks/executors/ask.py +30 -536
- package/src/ai_push_hooks/executors/exec.py +73 -818
- package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
- package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
- package/src/ai_push_hooks/executors/runners/process.py +97 -17
- package/src/ai_push_hooks/executors/runners/registry.py +31 -9
- package/src/ai_push_hooks/executors/step_commands.py +65 -20
- package/src/ai_push_hooks/git_utils.py +899 -0
- package/src/ai_push_hooks/hook.py +49 -13
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +19 -8
- package/src/ai_push_hooks/modules/docs.py +146 -96
- package/src/ai_push_hooks/modules/pr.py +19 -8
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +182 -105
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +48 -27
- 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/run.sh +0 -29
|
@@ -3,174 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import pathlib
|
|
6
|
-
import re
|
|
7
|
-
import shutil
|
|
8
6
|
import tempfile
|
|
9
|
-
from dataclasses import dataclass
|
|
10
7
|
from typing import Any
|
|
11
8
|
|
|
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
9
|
from ..types import HookError, RuntimeContext, StepConfig
|
|
20
|
-
from .exec import ensure_dir, 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
|
-
# A sentinel keeps the legacy helper's implicit flat-config behavior while
|
|
26
|
-
# allowing the runner boundary to explicitly pass ``None`` and omit optional
|
|
27
|
-
# OpenCode flags.
|
|
28
|
-
_DEFAULT_MODEL = object()
|
|
29
|
-
_DEFAULT_VARIANT = object()
|
|
30
|
-
PROVIDER_ENV_PREFIXES = (
|
|
31
|
-
"ANTHROPIC_",
|
|
32
|
-
"AWS_",
|
|
33
|
-
"AZURE_",
|
|
34
|
-
"COHERE_",
|
|
35
|
-
"DEEPSEEK_",
|
|
36
|
-
"GEMINI_",
|
|
37
|
-
"GOOGLE_",
|
|
38
|
-
"GROQ_",
|
|
39
|
-
"MISTRAL_",
|
|
40
|
-
"OPENAI_",
|
|
41
|
-
"OPENROUTER_",
|
|
42
|
-
"VERTEX_",
|
|
43
|
-
"XAI_",
|
|
44
|
-
)
|
|
45
|
-
SAFE_PROCESS_ENV_NAMES = frozenset(
|
|
46
|
-
{
|
|
47
|
-
"PATH",
|
|
48
|
-
"TMPDIR",
|
|
49
|
-
"TMP",
|
|
50
|
-
"TEMP",
|
|
51
|
-
"LANG",
|
|
52
|
-
"LC_ALL",
|
|
53
|
-
"LC_CTYPE",
|
|
54
|
-
"SYSTEMROOT",
|
|
55
|
-
}
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _actual_xdg_data_home() -> pathlib.Path:
|
|
60
|
-
configured = os.environ.get("XDG_DATA_HOME", "").strip()
|
|
61
|
-
if configured:
|
|
62
|
-
return pathlib.Path(configured).expanduser().resolve(strict=False)
|
|
63
|
-
return (pathlib.Path.home() / ".local" / "share").resolve(strict=False)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def opencode_isolation_env(
|
|
67
|
-
context: RuntimeContext,
|
|
68
|
-
security_config: dict[str, Any],
|
|
69
|
-
stage_name: str,
|
|
70
|
-
) -> dict[str, str | None]:
|
|
71
|
-
lexical_isolation_root = (
|
|
72
|
-
context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
|
|
73
|
-
)
|
|
74
|
-
if path_has_symlink(context.run_dir, lexical_isolation_root):
|
|
75
|
-
raise HookError(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
|
|
76
|
-
isolation_root = resolve_contained_path(
|
|
77
|
-
context.run_dir,
|
|
78
|
-
f"opencode-isolation/{sanitize_filename_component(stage_name)}",
|
|
79
|
-
"OpenCode isolation directory",
|
|
80
|
-
)
|
|
81
|
-
home = isolation_root / "home"
|
|
82
|
-
config_home = isolation_root / "config"
|
|
83
|
-
cache_home = isolation_root / "cache"
|
|
84
|
-
state_home = isolation_root / "state"
|
|
85
|
-
ensure_private_directory(isolation_root)
|
|
86
|
-
for path in (home, config_home, cache_home, state_home):
|
|
87
|
-
ensure_private_directory(path, private_root=isolation_root)
|
|
88
|
-
|
|
89
|
-
isolated: dict[str, str | None] = {
|
|
90
|
-
name: value
|
|
91
|
-
for name, value in os.environ.items()
|
|
92
|
-
if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
|
|
93
|
-
}
|
|
94
|
-
isolated.update({
|
|
95
|
-
"HOME": str(home),
|
|
96
|
-
"XDG_CONFIG_HOME": str(config_home),
|
|
97
|
-
"XDG_CACHE_HOME": str(cache_home),
|
|
98
|
-
"XDG_STATE_HOME": str(state_home),
|
|
99
|
-
"XDG_DATA_HOME": str(_actual_xdg_data_home()),
|
|
100
|
-
"OPENCODE_CONFIG_CONTENT": json.dumps(security_config, ensure_ascii=True),
|
|
101
|
-
"OPENCODE_CONFIG_DIR": str(config_home),
|
|
102
|
-
"OPENCODE_PURE": "true",
|
|
103
|
-
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
|
|
104
|
-
"OPENCODE_DISABLE_EXTERNAL_SKILLS": "true",
|
|
105
|
-
"OPENCODE_DISABLE_CLAUDE_CODE": "true",
|
|
106
|
-
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
|
|
107
|
-
"OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
|
|
108
|
-
"OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
|
|
109
|
-
"OPENCODE_DISABLE_SHARE": "true",
|
|
110
|
-
"OPENCODE_DISABLE_AUTOUPDATE": "true",
|
|
111
|
-
})
|
|
112
|
-
return isolated
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def non_agent_opencode_config() -> dict[str, Any]:
|
|
116
|
-
return {
|
|
117
|
-
"$schema": "https://opencode.ai/config.json",
|
|
118
|
-
"plugin": [],
|
|
119
|
-
"mcp": {},
|
|
120
|
-
"share": "disabled",
|
|
121
|
-
"instructions": [],
|
|
122
|
-
"formatter": False,
|
|
123
|
-
"lsp": False,
|
|
124
|
-
"command": {},
|
|
125
|
-
"permission": {"*": "deny"},
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
@dataclass
|
|
130
|
-
class OpenCodeRunResult:
|
|
131
|
-
output_text: str
|
|
132
|
-
session_id: str | None
|
|
133
|
-
stdout: str
|
|
134
|
-
stderr: str
|
|
135
|
-
return_code: int
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def sanitize_filename_component(value: str) -> str:
|
|
139
|
-
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
|
|
140
|
-
return cleaned.strip("-") or "value"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def resolve_opencode_executable() -> str:
|
|
144
|
-
opencode_path = shutil.which("opencode")
|
|
145
|
-
if opencode_path:
|
|
146
|
-
return opencode_path
|
|
147
|
-
cli_path = shutil.which("opencode-cli")
|
|
148
|
-
if cli_path:
|
|
149
|
-
return cli_path
|
|
150
|
-
raise HookError("opencode is required but not installed")
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def parse_opencode_json_run_output(raw: str) -> tuple[str | None, str]:
|
|
154
|
-
session_id: str | None = None
|
|
155
|
-
parts: list[str] = []
|
|
156
|
-
for line in raw.splitlines():
|
|
157
|
-
payload = line.strip()
|
|
158
|
-
if not payload:
|
|
159
|
-
continue
|
|
160
|
-
try:
|
|
161
|
-
event = json.loads(payload)
|
|
162
|
-
except json.JSONDecodeError:
|
|
163
|
-
continue
|
|
164
|
-
if not isinstance(event, dict):
|
|
165
|
-
continue
|
|
166
|
-
if session_id is None and isinstance(event.get("sessionID"), str):
|
|
167
|
-
session_id = str(event["sessionID"]).strip()
|
|
168
|
-
if event.get("type") != "text":
|
|
169
|
-
continue
|
|
170
|
-
part = event.get("part")
|
|
171
|
-
if isinstance(part, dict) and isinstance(part.get("text"), str) and part["text"].strip():
|
|
172
|
-
parts.append(part["text"])
|
|
173
|
-
return session_id, "\n".join(parts).strip()
|
|
174
10
|
|
|
175
11
|
|
|
176
12
|
def extract_json_array(text: str) -> list[Any]:
|
|
@@ -195,7 +31,9 @@ def extract_json_object(text: str) -> dict[str, Any]:
|
|
|
195
31
|
try:
|
|
196
32
|
payload = json.loads(text[start : end + 1])
|
|
197
33
|
except json.JSONDecodeError as exc:
|
|
198
|
-
raise HookError(
|
|
34
|
+
raise HookError(
|
|
35
|
+
f"Failed to parse JSON object from model output: {exc}"
|
|
36
|
+
) from exc
|
|
199
37
|
if not isinstance(payload, dict):
|
|
200
38
|
raise HookError("Model output JSON is not an object")
|
|
201
39
|
return payload
|
|
@@ -205,7 +43,9 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
205
43
|
if schema is None:
|
|
206
44
|
return payload
|
|
207
45
|
if schema == "string_array":
|
|
208
|
-
if not isinstance(payload, list) or not all(
|
|
46
|
+
if not isinstance(payload, list) or not all(
|
|
47
|
+
isinstance(item, str) for item in payload
|
|
48
|
+
):
|
|
209
49
|
raise HookError("Expected schema string_array")
|
|
210
50
|
return payload
|
|
211
51
|
if schema == "docs_issue_array":
|
|
@@ -214,7 +54,10 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
214
54
|
for item in payload:
|
|
215
55
|
if not isinstance(item, dict):
|
|
216
56
|
raise HookError("docs_issue_array items must be objects")
|
|
217
|
-
if
|
|
57
|
+
if (
|
|
58
|
+
not str(item.get("file", "")).strip()
|
|
59
|
+
or not str(item.get("description", "")).strip()
|
|
60
|
+
):
|
|
218
61
|
raise HookError("docs_issue_array items require file and description")
|
|
219
62
|
return payload
|
|
220
63
|
if schema == "beads_alignment_result":
|
|
@@ -222,376 +65,25 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
222
65
|
raise HookError("Expected schema beads_alignment_result")
|
|
223
66
|
commands = payload.get("commands", [])
|
|
224
67
|
if commands is not None and (
|
|
225
|
-
not isinstance(commands, list)
|
|
68
|
+
not isinstance(commands, list)
|
|
69
|
+
or not all(isinstance(item, str) for item in commands)
|
|
226
70
|
):
|
|
227
|
-
raise HookError(
|
|
71
|
+
raise HookError(
|
|
72
|
+
"beads_alignment_result.commands must be an array of strings"
|
|
73
|
+
)
|
|
228
74
|
return payload
|
|
229
75
|
if schema == "pr_create_payload":
|
|
230
76
|
if not isinstance(payload, dict):
|
|
231
77
|
raise HookError("Expected schema pr_create_payload")
|
|
78
|
+
for field in ("title", "body"):
|
|
79
|
+
if field in payload and not isinstance(payload[field], str):
|
|
80
|
+
raise HookError(f"pr_create_payload.{field} must be a string")
|
|
81
|
+
if "draft" in payload and not isinstance(payload["draft"], bool):
|
|
82
|
+
raise HookError("pr_create_payload.draft must be a boolean")
|
|
232
83
|
return payload
|
|
233
84
|
raise HookError(f"Unsupported schema: {schema}")
|
|
234
85
|
|
|
235
86
|
|
|
236
|
-
def _transcript_dir(context: RuntimeContext) -> pathlib.Path | None:
|
|
237
|
-
if not context.config.logging.capture_llm_transcript:
|
|
238
|
-
return None
|
|
239
|
-
return ensure_dir(
|
|
240
|
-
resolve_storage_path(
|
|
241
|
-
context.repo_root,
|
|
242
|
-
context.git_dir,
|
|
243
|
-
context.config.logging.transcript_dir,
|
|
244
|
-
)
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
def export_opencode_session_json(
|
|
249
|
-
context: RuntimeContext,
|
|
250
|
-
session_id: str,
|
|
251
|
-
export_path: pathlib.Path,
|
|
252
|
-
) -> bool:
|
|
253
|
-
with tempfile.TemporaryDirectory(
|
|
254
|
-
prefix="ai-push-hooks-session-export-"
|
|
255
|
-
) as temporary_directory:
|
|
256
|
-
completed = run_command(
|
|
257
|
-
[
|
|
258
|
-
context.opencode_executable or resolve_opencode_executable(),
|
|
259
|
-
"export",
|
|
260
|
-
session_id,
|
|
261
|
-
"--pure",
|
|
262
|
-
],
|
|
263
|
-
cwd=pathlib.Path(temporary_directory).resolve(strict=True),
|
|
264
|
-
timeout=context.config.llm.timeout_seconds,
|
|
265
|
-
check=False,
|
|
266
|
-
env=opencode_isolation_env(context, non_agent_opencode_config(), "session-export"),
|
|
267
|
-
inherit_env=False,
|
|
268
|
-
)
|
|
269
|
-
if completed.returncode != 0:
|
|
270
|
-
return False
|
|
271
|
-
payload = (completed.stdout or "").strip()
|
|
272
|
-
if not payload:
|
|
273
|
-
return False
|
|
274
|
-
write_text_no_follow(export_path, payload + "\n")
|
|
275
|
-
return True
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def delete_opencode_session(context: RuntimeContext, session_id: str) -> bool:
|
|
279
|
-
with tempfile.TemporaryDirectory(
|
|
280
|
-
prefix="ai-push-hooks-session-delete-"
|
|
281
|
-
) as temporary_directory:
|
|
282
|
-
completed = run_command(
|
|
283
|
-
[
|
|
284
|
-
context.opencode_executable or resolve_opencode_executable(),
|
|
285
|
-
"session",
|
|
286
|
-
"delete",
|
|
287
|
-
session_id,
|
|
288
|
-
"--pure",
|
|
289
|
-
],
|
|
290
|
-
cwd=pathlib.Path(temporary_directory).resolve(strict=True),
|
|
291
|
-
timeout=context.config.llm.timeout_seconds,
|
|
292
|
-
check=False,
|
|
293
|
-
env=opencode_isolation_env(context, non_agent_opencode_config(), "session-delete"),
|
|
294
|
-
inherit_env=False,
|
|
295
|
-
)
|
|
296
|
-
return completed.returncode == 0
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_id: str | None) -> None:
|
|
300
|
-
"""Capture and finalize a session without retaining it on export failure.
|
|
301
|
-
|
|
302
|
-
Transcript capture is best effort. A failed or interrupted export emits a
|
|
303
|
-
visible warning, then the configured deletion policy still runs so a
|
|
304
|
-
failed capture does not silently retain provider data.
|
|
305
|
-
"""
|
|
306
|
-
if not session_id:
|
|
307
|
-
return
|
|
308
|
-
transcript_dir = _transcript_dir(context)
|
|
309
|
-
if transcript_dir is not None:
|
|
310
|
-
export_name = (
|
|
311
|
-
f"{sanitize_filename_component(context.run_id)}-"
|
|
312
|
-
f"{sanitize_filename_component(stage_name)}-"
|
|
313
|
-
f"{sanitize_filename_component(session_id)}.json"
|
|
314
|
-
)
|
|
315
|
-
export_path = resolve_contained_path(
|
|
316
|
-
transcript_dir,
|
|
317
|
-
export_name,
|
|
318
|
-
"OpenCode transcript path",
|
|
319
|
-
)
|
|
320
|
-
export_failure: str | None = None
|
|
321
|
-
try:
|
|
322
|
-
exported = export_opencode_session_json(context, session_id, export_path)
|
|
323
|
-
except Exception as exc: # noqa: BLE001
|
|
324
|
-
exported = False
|
|
325
|
-
export_failure = type(exc).__name__
|
|
326
|
-
if not exported:
|
|
327
|
-
context.logger.warn(
|
|
328
|
-
"llm.transcript_export_failed",
|
|
329
|
-
"Could not capture the OpenCode transcript; applying configured session deletion.",
|
|
330
|
-
stage_name=stage_name,
|
|
331
|
-
session_id=session_id,
|
|
332
|
-
reason=export_failure or "export returned no transcript",
|
|
333
|
-
)
|
|
334
|
-
if context.config.llm.delete_session_after_run:
|
|
335
|
-
delete_opencode_session(context, session_id)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def build_opencode_security_config(
|
|
339
|
-
agent_policy: str,
|
|
340
|
-
allow_paths: tuple[str, ...] = (),
|
|
341
|
-
*,
|
|
342
|
-
non_vcs_working_directory: pathlib.Path | None = None,
|
|
343
|
-
project_read_root: pathlib.Path | None = None,
|
|
344
|
-
) -> tuple[str, dict[str, Any]]:
|
|
345
|
-
permissions: dict[str, Any] = {
|
|
346
|
-
"*": "deny",
|
|
347
|
-
"read": "deny",
|
|
348
|
-
"glob": "deny",
|
|
349
|
-
"grep": "deny",
|
|
350
|
-
"list": "deny",
|
|
351
|
-
"edit": "deny",
|
|
352
|
-
"bash": "deny",
|
|
353
|
-
"task": "deny",
|
|
354
|
-
"external_directory": "deny",
|
|
355
|
-
"webfetch": "deny",
|
|
356
|
-
"websearch": "deny",
|
|
357
|
-
"lsp": "deny",
|
|
358
|
-
"skill": "deny",
|
|
359
|
-
"todowrite": "deny",
|
|
360
|
-
"question": "deny",
|
|
361
|
-
"doom_loop": "deny",
|
|
362
|
-
}
|
|
363
|
-
if agent_policy == "read-only":
|
|
364
|
-
agent_name = OPENCODE_READ_ONLY_AGENT
|
|
365
|
-
description = "Read-only ai-push-hooks analysis agent"
|
|
366
|
-
elif agent_policy == "apply":
|
|
367
|
-
if not allow_paths:
|
|
368
|
-
raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
|
|
369
|
-
agent_name = OPENCODE_APPLY_AGENT
|
|
370
|
-
description = "Path-restricted ai-push-hooks apply agent"
|
|
371
|
-
permissions["read"] = "allow"
|
|
372
|
-
permission_patterns: set[str] = set()
|
|
373
|
-
for pattern in allow_paths:
|
|
374
|
-
permission_patterns.add(pattern)
|
|
375
|
-
collapsed = pattern
|
|
376
|
-
while "**/" in collapsed:
|
|
377
|
-
collapsed = collapsed.replace("**/", "", 1)
|
|
378
|
-
permission_patterns.add(collapsed)
|
|
379
|
-
protected_patterns = {".git", ".git/**"}
|
|
380
|
-
if non_vcs_working_directory is not None:
|
|
381
|
-
# OpenCode 1.18.29 assigns `/` as the worktree for a directory
|
|
382
|
-
# without VCS metadata. Its write and edit tools then request the
|
|
383
|
-
# `edit` permission using paths relative to that filesystem root,
|
|
384
|
-
# not relative to the process cwd. Keep the staging checkout free
|
|
385
|
-
# of Git metadata and qualify only its allowlisted paths.
|
|
386
|
-
anchor = pathlib.Path(non_vcs_working_directory.anchor)
|
|
387
|
-
prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
|
|
388
|
-
permission_patterns = {
|
|
389
|
-
f"{prefix}/{pattern}" if prefix else pattern
|
|
390
|
-
for pattern in permission_patterns
|
|
391
|
-
}
|
|
392
|
-
protected_patterns = {
|
|
393
|
-
f"{prefix}/{pattern}" if prefix else pattern
|
|
394
|
-
for pattern in protected_patterns
|
|
395
|
-
}
|
|
396
|
-
edit_permissions = {
|
|
397
|
-
"*": "deny",
|
|
398
|
-
**{pattern: "allow" for pattern in sorted(permission_patterns)},
|
|
399
|
-
}
|
|
400
|
-
for pattern in sorted(protected_patterns):
|
|
401
|
-
edit_permissions[pattern] = "deny"
|
|
402
|
-
permissions["edit"] = edit_permissions
|
|
403
|
-
else:
|
|
404
|
-
raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
|
|
405
|
-
|
|
406
|
-
if project_read_root is not None:
|
|
407
|
-
# OpenCode's file permission matcher uses paths relative to its
|
|
408
|
-
# filesystem worktree. A real Git checkout is already rooted at the
|
|
409
|
-
# requested worktree, so absolute filesystem-root-relative patterns
|
|
410
|
-
# would never match README.md-style tool paths. A non-VCS projection
|
|
411
|
-
# is treated as a filesystem worktree rooted at `/`, so qualify those
|
|
412
|
-
# patterns with the projection's absolute prefix.
|
|
413
|
-
root = project_read_root.resolve(strict=False)
|
|
414
|
-
if (root / ".git").exists():
|
|
415
|
-
rooted_permissions: str | dict[str, str] = "allow"
|
|
416
|
-
else:
|
|
417
|
-
anchor = pathlib.Path(root.anchor)
|
|
418
|
-
prefix = root.relative_to(anchor).as_posix()
|
|
419
|
-
rooted_permissions = {
|
|
420
|
-
"*": "deny",
|
|
421
|
-
prefix: "allow",
|
|
422
|
-
f"{prefix}/**": "allow",
|
|
423
|
-
}
|
|
424
|
-
for tool in ("read", "list", "glob", "grep"):
|
|
425
|
-
permissions[tool] = (
|
|
426
|
-
dict(rooted_permissions)
|
|
427
|
-
if isinstance(rooted_permissions, dict)
|
|
428
|
-
else rooted_permissions
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
return agent_name, {
|
|
432
|
-
"$schema": "https://opencode.ai/config.json",
|
|
433
|
-
"plugin": [],
|
|
434
|
-
"mcp": {},
|
|
435
|
-
"share": "disabled",
|
|
436
|
-
"instructions": [],
|
|
437
|
-
"formatter": False,
|
|
438
|
-
"lsp": False,
|
|
439
|
-
"command": {},
|
|
440
|
-
"agent": {
|
|
441
|
-
agent_name: {
|
|
442
|
-
"mode": "primary",
|
|
443
|
-
"description": description,
|
|
444
|
-
"permission": permissions,
|
|
445
|
-
}
|
|
446
|
-
},
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def validate_opencode_attachments(
|
|
451
|
-
context: RuntimeContext,
|
|
452
|
-
files: list[pathlib.Path],
|
|
453
|
-
) -> list[pathlib.Path]:
|
|
454
|
-
run_root = context.run_dir.resolve(strict=True)
|
|
455
|
-
validated: list[pathlib.Path] = []
|
|
456
|
-
for file_path in files:
|
|
457
|
-
lexical_path = pathlib.Path(os.path.abspath(file_path))
|
|
458
|
-
if not is_path_within(lexical_path, run_root):
|
|
459
|
-
raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
|
|
460
|
-
if path_has_symlink(run_root, lexical_path):
|
|
461
|
-
raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
|
|
462
|
-
resolved_path = lexical_path.resolve(strict=True)
|
|
463
|
-
if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
|
|
464
|
-
raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
|
|
465
|
-
validated.append(resolved_path)
|
|
466
|
-
return validated
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
def call_opencode(
|
|
470
|
-
context: RuntimeContext,
|
|
471
|
-
stage_name: str,
|
|
472
|
-
purpose: str,
|
|
473
|
-
prompt: str,
|
|
474
|
-
files: list[pathlib.Path],
|
|
475
|
-
*,
|
|
476
|
-
agent: str,
|
|
477
|
-
allow_paths: tuple[str, ...] = (),
|
|
478
|
-
working_directory: pathlib.Path | None = None,
|
|
479
|
-
attempt: int | None = None,
|
|
480
|
-
total_attempts: int | None = None,
|
|
481
|
-
existing_session_id: str | None = None,
|
|
482
|
-
model: str | None | object = _DEFAULT_MODEL,
|
|
483
|
-
variant: str | None | object = _DEFAULT_VARIANT,
|
|
484
|
-
project_access: str = "artifacts",
|
|
485
|
-
) -> OpenCodeRunResult:
|
|
486
|
-
"""Run OpenCode with a policy-specific isolated working directory.
|
|
487
|
-
|
|
488
|
-
Apply callers must provide the hook-owned, non-VCS staging directory built
|
|
489
|
-
by ``run_apply_step``; this is an internal precondition rather than a
|
|
490
|
-
general repository-working-directory interface.
|
|
491
|
-
"""
|
|
492
|
-
if agent not in OPENCODE_AGENT_POLICIES:
|
|
493
|
-
raise HookError(f"Unsupported OpenCode agent policy: {agent}")
|
|
494
|
-
if agent == "apply" and not allow_paths:
|
|
495
|
-
raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
|
|
496
|
-
if agent == "apply" and working_directory is None:
|
|
497
|
-
raise HookError("OpenCode apply agent requires an isolated staging directory")
|
|
498
|
-
if agent == "read-only" and allow_paths:
|
|
499
|
-
raise HookError("OpenCode read-only agent does not accept write paths")
|
|
500
|
-
if project_access not in {"artifacts", "project"}:
|
|
501
|
-
raise HookError(f"Unsupported OpenCode project access: {project_access}")
|
|
502
|
-
if project_access == "project" and working_directory is None:
|
|
503
|
-
raise HookError("OpenCode project access requires an explicit working directory")
|
|
504
|
-
|
|
505
|
-
validated_files = validate_opencode_attachments(context, files)
|
|
506
|
-
if working_directory is None:
|
|
507
|
-
resolved_working_directory = None
|
|
508
|
-
else:
|
|
509
|
-
try:
|
|
510
|
-
resolved_working_directory = working_directory.resolve(strict=True)
|
|
511
|
-
except (OSError, RuntimeError) as exc:
|
|
512
|
-
raise HookError(
|
|
513
|
-
f"OpenCode working directory must be an existing directory: {working_directory}"
|
|
514
|
-
) from exc
|
|
515
|
-
if not resolved_working_directory.is_dir():
|
|
516
|
-
raise HookError(
|
|
517
|
-
f"OpenCode working directory must be an existing directory: {working_directory}"
|
|
518
|
-
)
|
|
519
|
-
agent_name, security_config = build_opencode_security_config(
|
|
520
|
-
agent,
|
|
521
|
-
allow_paths,
|
|
522
|
-
non_vcs_working_directory=(
|
|
523
|
-
resolved_working_directory if agent == "apply" else None
|
|
524
|
-
),
|
|
525
|
-
project_read_root=(
|
|
526
|
-
resolved_working_directory if project_access == "project" else None
|
|
527
|
-
),
|
|
528
|
-
)
|
|
529
|
-
executable = context.opencode_executable or resolve_opencode_executable()
|
|
530
|
-
effective_model = (
|
|
531
|
-
context.config.llm.model if model is _DEFAULT_MODEL else model
|
|
532
|
-
)
|
|
533
|
-
effective_variant = (
|
|
534
|
-
context.config.llm.variant if variant is _DEFAULT_VARIANT else variant
|
|
535
|
-
)
|
|
536
|
-
context.logger.llm_call(stage_name, purpose, effective_model or "", attempt, total_attempts)
|
|
537
|
-
isolated_env = opencode_isolation_env(context, security_config, stage_name)
|
|
538
|
-
cmd = [
|
|
539
|
-
executable,
|
|
540
|
-
"run",
|
|
541
|
-
"--agent",
|
|
542
|
-
agent_name,
|
|
543
|
-
"--pure",
|
|
544
|
-
"--format",
|
|
545
|
-
"json",
|
|
546
|
-
]
|
|
547
|
-
if effective_model:
|
|
548
|
-
cmd.extend(["--model", effective_model])
|
|
549
|
-
if effective_variant:
|
|
550
|
-
cmd.extend(["--variant", effective_variant])
|
|
551
|
-
if existing_session_id:
|
|
552
|
-
cmd.extend(["--session", existing_session_id])
|
|
553
|
-
else:
|
|
554
|
-
cmd.extend(["--title", f"{context.config.llm.session_title_prefix} {context.run_id} {stage_name}"])
|
|
555
|
-
for file_path in validated_files:
|
|
556
|
-
cmd.extend(["--file", str(file_path)])
|
|
557
|
-
cmd.extend(["--", prompt])
|
|
558
|
-
|
|
559
|
-
if working_directory is None:
|
|
560
|
-
with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as temporary_directory:
|
|
561
|
-
completed = run_command(
|
|
562
|
-
cmd,
|
|
563
|
-
cwd=pathlib.Path(temporary_directory).resolve(strict=True),
|
|
564
|
-
timeout=context.config.llm.timeout_seconds,
|
|
565
|
-
check=False,
|
|
566
|
-
env=isolated_env,
|
|
567
|
-
inherit_env=False,
|
|
568
|
-
)
|
|
569
|
-
else:
|
|
570
|
-
completed = run_command(
|
|
571
|
-
cmd,
|
|
572
|
-
cwd=resolved_working_directory,
|
|
573
|
-
timeout=context.config.llm.timeout_seconds,
|
|
574
|
-
check=False,
|
|
575
|
-
env=isolated_env,
|
|
576
|
-
inherit_env=False,
|
|
577
|
-
)
|
|
578
|
-
session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
|
|
579
|
-
stdout = completed.stdout or ""
|
|
580
|
-
stderr = completed.stderr or ""
|
|
581
|
-
if context.config.logging.print_llm_output and text_output:
|
|
582
|
-
# Print normalized assistant text only. OpenCode's JSONL stream can
|
|
583
|
-
# contain provider diagnostics and tool payloads which must not become
|
|
584
|
-
# an accidental credential/prompt log.
|
|
585
|
-
print(text_output)
|
|
586
|
-
return OpenCodeRunResult(
|
|
587
|
-
output_text=text_output if text_output else stdout.strip(),
|
|
588
|
-
session_id=session_id or existing_session_id,
|
|
589
|
-
stdout=stdout,
|
|
590
|
-
stderr=stderr,
|
|
591
|
-
return_code=completed.returncode,
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
|
|
595
87
|
def _safe_invalid_output(invocation: Any, output: str) -> str:
|
|
596
88
|
from .runners.contracts import request_sensitive_diagnostics
|
|
597
89
|
|
|
@@ -672,7 +164,9 @@ def run_ask_step(
|
|
|
672
164
|
f"{_safe_invalid_output(invocation, last_output)}"
|
|
673
165
|
) from exc
|
|
674
166
|
|
|
675
|
-
snippet = last_output[
|
|
167
|
+
snippet = last_output[
|
|
168
|
+
: context.config.llm.invalid_json_feedback_max_chars
|
|
169
|
+
]
|
|
676
170
|
suffix = (
|
|
677
171
|
"Return ONLY valid JSON array."
|
|
678
172
|
if expects_json_array
|
|
@@ -690,7 +184,11 @@ def run_ask_step(
|
|
|
690
184
|
|
|
691
185
|
session = result.session
|
|
692
186
|
can_resume = bool(
|
|
693
|
-
getattr(
|
|
187
|
+
getattr(
|
|
188
|
+
getattr(invocation.runner, "capabilities", None),
|
|
189
|
+
"supports_resume",
|
|
190
|
+
False,
|
|
191
|
+
)
|
|
694
192
|
and session is not None
|
|
695
193
|
and session.session_id
|
|
696
194
|
and session.resumable
|
|
@@ -704,13 +202,9 @@ def run_ask_step(
|
|
|
704
202
|
retry_reason = "runner does not support resume"
|
|
705
203
|
retry_message = "Retrying with a fresh runner invocation."
|
|
706
204
|
if retry_reason == "runner does not support resume":
|
|
707
|
-
retry_message =
|
|
708
|
-
"Retrying with a fresh runner invocation; unsupported session reuse."
|
|
709
|
-
)
|
|
205
|
+
retry_message = "Retrying with a fresh runner invocation; unsupported session reuse."
|
|
710
206
|
elif retry_reason == "session absent":
|
|
711
|
-
retry_message =
|
|
712
|
-
"Retrying with a fresh runner invocation; no reusable session was captured."
|
|
713
|
-
)
|
|
207
|
+
retry_message = "Retrying with a fresh runner invocation; no reusable session was captured."
|
|
714
208
|
context.logger.status(
|
|
715
209
|
"llm.retry_fresh_session",
|
|
716
210
|
retry_message,
|