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.
Files changed (37) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +80 -525
  3. package/SECURITY.md +102 -14
  4. package/ai-push-hooks.toml +9 -2
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/package.json +3 -2
  7. package/pyproject.toml +1 -1
  8. package/src/ai_push_hooks/artifacts.py +67 -13
  9. package/src/ai_push_hooks/config.py +575 -22
  10. package/src/ai_push_hooks/engine.py +116 -7
  11. package/src/ai_push_hooks/executors/apply.py +75 -36
  12. package/src/ai_push_hooks/executors/ask.py +224 -0
  13. package/src/ai_push_hooks/executors/exec.py +17 -801
  14. package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
  15. package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
  16. package/src/ai_push_hooks/executors/runners/claude.py +286 -0
  17. package/src/ai_push_hooks/executors/runners/codex.py +254 -0
  18. package/src/ai_push_hooks/executors/runners/command.py +178 -0
  19. package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
  20. package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
  22. package/src/ai_push_hooks/executors/runners/process.py +464 -0
  23. package/src/ai_push_hooks/executors/runners/registry.py +117 -0
  24. package/src/ai_push_hooks/executors/step_commands.py +478 -0
  25. package/src/ai_push_hooks/git_utils.py +834 -0
  26. package/src/ai_push_hooks/hook.py +1 -1
  27. package/src/ai_push_hooks/modules/beads.py +1 -1
  28. package/src/ai_push_hooks/modules/docs.py +129 -89
  29. package/src/ai_push_hooks/modules/pr.py +1 -1
  30. package/src/ai_push_hooks/plugin_loader.py +422 -0
  31. package/src/ai_push_hooks/plugins.py +134 -0
  32. package/src/ai_push_hooks/prompts_builtin.py +9 -2
  33. package/src/ai_push_hooks/types.py +407 -75
  34. package/vendor/README.md +15 -0
  35. package/vendor/requirements.txt +1 -0
  36. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  37. 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