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
|
@@ -0,0 +1,276 @@
|
|
|
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(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
|
|
75
|
+
isolation_root = resolve_contained_path(
|
|
76
|
+
context.run_dir,
|
|
77
|
+
f"opencode-isolation/{sanitize_filename_component(stage_name)}",
|
|
78
|
+
"OpenCode isolation directory",
|
|
79
|
+
)
|
|
80
|
+
home = isolation_root / "home"
|
|
81
|
+
config_home = isolation_root / "config"
|
|
82
|
+
cache_home = isolation_root / "cache"
|
|
83
|
+
state_home = isolation_root / "state"
|
|
84
|
+
ensure_private_directory(isolation_root)
|
|
85
|
+
for path in (home, config_home, cache_home, state_home):
|
|
86
|
+
ensure_private_directory(path, private_root=isolation_root)
|
|
87
|
+
|
|
88
|
+
isolated: dict[str, str | None] = {
|
|
89
|
+
name: value
|
|
90
|
+
for name, value in os.environ.items()
|
|
91
|
+
if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
|
|
92
|
+
}
|
|
93
|
+
isolated.update(
|
|
94
|
+
{
|
|
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
|
+
)
|
|
113
|
+
return isolated
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def non_agent_opencode_config() -> dict[str, Any]:
|
|
117
|
+
return {
|
|
118
|
+
"$schema": "https://opencode.ai/config.json",
|
|
119
|
+
"plugin": [],
|
|
120
|
+
"mcp": {},
|
|
121
|
+
"share": "disabled",
|
|
122
|
+
"instructions": [],
|
|
123
|
+
"formatter": False,
|
|
124
|
+
"lsp": False,
|
|
125
|
+
"command": {},
|
|
126
|
+
"permission": {"*": "deny"},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sanitize_filename_component(value: str) -> str:
|
|
131
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
|
|
132
|
+
return cleaned.strip("-") or "value"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve_opencode_executable() -> str:
|
|
136
|
+
opencode_path = shutil.which("opencode")
|
|
137
|
+
if opencode_path:
|
|
138
|
+
return opencode_path
|
|
139
|
+
cli_path = shutil.which("opencode-cli")
|
|
140
|
+
if cli_path:
|
|
141
|
+
return cli_path
|
|
142
|
+
raise HookError("opencode is required but not installed")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _transcript_dir(context: RuntimeContext) -> pathlib.Path | None:
|
|
146
|
+
if not context.config.logging.capture_llm_transcript:
|
|
147
|
+
return None
|
|
148
|
+
return ensure_dir(
|
|
149
|
+
resolve_storage_path(
|
|
150
|
+
context.repo_root,
|
|
151
|
+
context.git_dir,
|
|
152
|
+
context.config.logging.transcript_dir,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_opencode_security_config(
|
|
158
|
+
agent_policy: str,
|
|
159
|
+
allow_paths: tuple[str, ...] = (),
|
|
160
|
+
*,
|
|
161
|
+
non_vcs_working_directory: pathlib.Path | None = None,
|
|
162
|
+
project_read_root: pathlib.Path | None = None,
|
|
163
|
+
) -> tuple[str, dict[str, Any]]:
|
|
164
|
+
permissions: dict[str, Any] = {
|
|
165
|
+
"*": "deny",
|
|
166
|
+
"read": "deny",
|
|
167
|
+
"glob": "deny",
|
|
168
|
+
"grep": "deny",
|
|
169
|
+
"list": "deny",
|
|
170
|
+
"edit": "deny",
|
|
171
|
+
"bash": "deny",
|
|
172
|
+
"task": "deny",
|
|
173
|
+
"external_directory": "deny",
|
|
174
|
+
"webfetch": "deny",
|
|
175
|
+
"websearch": "deny",
|
|
176
|
+
"lsp": "deny",
|
|
177
|
+
"skill": "deny",
|
|
178
|
+
"todowrite": "deny",
|
|
179
|
+
"question": "deny",
|
|
180
|
+
"doom_loop": "deny",
|
|
181
|
+
}
|
|
182
|
+
if agent_policy == "read-only":
|
|
183
|
+
agent_name = OPENCODE_READ_ONLY_AGENT
|
|
184
|
+
description = "Read-only ai-push-hooks analysis agent"
|
|
185
|
+
elif agent_policy == "apply":
|
|
186
|
+
if not allow_paths:
|
|
187
|
+
raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
|
|
188
|
+
agent_name = OPENCODE_APPLY_AGENT
|
|
189
|
+
description = "Path-restricted ai-push-hooks apply agent"
|
|
190
|
+
permissions["read"] = "allow"
|
|
191
|
+
permission_patterns: set[str] = set()
|
|
192
|
+
for pattern in allow_paths:
|
|
193
|
+
permission_patterns.add(pattern)
|
|
194
|
+
collapsed = pattern
|
|
195
|
+
while "**/" in collapsed:
|
|
196
|
+
collapsed = collapsed.replace("**/", "", 1)
|
|
197
|
+
permission_patterns.add(collapsed)
|
|
198
|
+
protected_patterns = {".git", ".git/**"}
|
|
199
|
+
if non_vcs_working_directory is not None:
|
|
200
|
+
# OpenCode assigns `/` as the worktree for a directory without VCS
|
|
201
|
+
# metadata, so qualify allowlisted paths with that filesystem root.
|
|
202
|
+
anchor = pathlib.Path(non_vcs_working_directory.anchor)
|
|
203
|
+
prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
|
|
204
|
+
permission_patterns = {
|
|
205
|
+
f"{prefix}/{pattern}" if prefix else pattern
|
|
206
|
+
for pattern in permission_patterns
|
|
207
|
+
}
|
|
208
|
+
protected_patterns = {
|
|
209
|
+
f"{prefix}/{pattern}" if prefix else pattern
|
|
210
|
+
for pattern in protected_patterns
|
|
211
|
+
}
|
|
212
|
+
edit_permissions = {
|
|
213
|
+
"*": "deny",
|
|
214
|
+
**{pattern: "allow" for pattern in sorted(permission_patterns)},
|
|
215
|
+
}
|
|
216
|
+
for pattern in sorted(protected_patterns):
|
|
217
|
+
edit_permissions[pattern] = "deny"
|
|
218
|
+
permissions["edit"] = edit_permissions
|
|
219
|
+
else:
|
|
220
|
+
raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
|
|
221
|
+
|
|
222
|
+
if project_read_root is not None:
|
|
223
|
+
root = project_read_root.resolve(strict=False)
|
|
224
|
+
if (root / ".git").exists():
|
|
225
|
+
rooted_permissions: str | dict[str, str] = "allow"
|
|
226
|
+
else:
|
|
227
|
+
anchor = pathlib.Path(root.anchor)
|
|
228
|
+
prefix = root.relative_to(anchor).as_posix()
|
|
229
|
+
rooted_permissions = {
|
|
230
|
+
"*": "deny",
|
|
231
|
+
prefix: "allow",
|
|
232
|
+
f"{prefix}/**": "allow",
|
|
233
|
+
}
|
|
234
|
+
for tool in ("read", "list", "glob", "grep"):
|
|
235
|
+
permissions[tool] = (
|
|
236
|
+
dict(rooted_permissions)
|
|
237
|
+
if isinstance(rooted_permissions, dict)
|
|
238
|
+
else rooted_permissions
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return agent_name, {
|
|
242
|
+
"$schema": "https://opencode.ai/config.json",
|
|
243
|
+
"plugin": [],
|
|
244
|
+
"mcp": {},
|
|
245
|
+
"share": "disabled",
|
|
246
|
+
"instructions": [],
|
|
247
|
+
"formatter": False,
|
|
248
|
+
"lsp": False,
|
|
249
|
+
"command": {},
|
|
250
|
+
"agent": {
|
|
251
|
+
agent_name: {
|
|
252
|
+
"mode": "primary",
|
|
253
|
+
"description": description,
|
|
254
|
+
"permission": permissions,
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def validate_hook_owned_artifacts(
|
|
261
|
+
context: RuntimeContext,
|
|
262
|
+
files: list[pathlib.Path],
|
|
263
|
+
) -> list[pathlib.Path]:
|
|
264
|
+
run_root = context.run_dir.resolve(strict=True)
|
|
265
|
+
validated: list[pathlib.Path] = []
|
|
266
|
+
for file_path in files:
|
|
267
|
+
lexical_path = pathlib.Path(os.path.abspath(file_path))
|
|
268
|
+
if not is_path_within(lexical_path, run_root):
|
|
269
|
+
raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
|
|
270
|
+
if path_has_symlink(run_root, lexical_path):
|
|
271
|
+
raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
|
|
272
|
+
resolved_path = lexical_path.resolve(strict=True)
|
|
273
|
+
if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
|
|
274
|
+
raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
|
|
275
|
+
validated.append(resolved_path)
|
|
276
|
+
return validated
|