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