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,16 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import pathlib
|
|
4
|
+
from collections.abc import Callable
|
|
4
5
|
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
5
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
from .artifacts import ArtifactStore
|
|
8
9
|
from .config import resolve_prompt_text
|
|
9
10
|
from .executors.apply import run_apply_step
|
|
10
|
-
from .executors.assertions import ASSERTION_HANDLERS
|
|
11
|
-
from .executors.exec import EXEC_HANDLERS, env_bool
|
|
12
11
|
from .executors.ask import run_ask_step
|
|
12
|
+
from .executors.assertions import ASSERTION_HANDLERS
|
|
13
|
+
from .executors.exec import EXEC_HANDLERS
|
|
13
14
|
from .executors.step_commands import execute_step_command
|
|
15
|
+
from .git_utils import env_bool
|
|
14
16
|
from .modules import COLLECTORS
|
|
15
17
|
from .plugin_loader import PluginDispatcher
|
|
16
18
|
from .plugins import (
|
|
@@ -29,8 +31,12 @@ from .types import (
|
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
CollectorHandler = Callable[[RuntimeContext, ModuleRuntimeState], CollectorResult]
|
|
32
|
-
ExecHandler = Callable[
|
|
33
|
-
|
|
34
|
+
ExecHandler = Callable[
|
|
35
|
+
[RuntimeContext, ModuleRuntimeState, StepConfig, list[pathlib.Path]], dict[str, Any]
|
|
36
|
+
]
|
|
37
|
+
AssertionHandler = Callable[
|
|
38
|
+
[RuntimeContext, StepConfig, list[pathlib.Path]], dict[str, Any]
|
|
39
|
+
]
|
|
34
40
|
|
|
35
41
|
|
|
36
42
|
class WorkflowEngine:
|
|
@@ -41,8 +47,20 @@ class WorkflowEngine:
|
|
|
41
47
|
collectors: dict[str, CollectorHandler] | None = None,
|
|
42
48
|
exec_handlers: dict[str, ExecHandler] | None = None,
|
|
43
49
|
assertion_handlers: dict[str, AssertionHandler] | None = None,
|
|
44
|
-
ask_executor: Callable[
|
|
45
|
-
|
|
50
|
+
ask_executor: Callable[
|
|
51
|
+
[RuntimeContext, StepConfig, str, list[pathlib.Path], str], Any
|
|
52
|
+
] = run_ask_step,
|
|
53
|
+
apply_executor: Callable[
|
|
54
|
+
[
|
|
55
|
+
RuntimeContext,
|
|
56
|
+
ModuleRuntimeState,
|
|
57
|
+
StepConfig,
|
|
58
|
+
str,
|
|
59
|
+
list[pathlib.Path],
|
|
60
|
+
str,
|
|
61
|
+
],
|
|
62
|
+
dict[str, object],
|
|
63
|
+
] = run_apply_step,
|
|
46
64
|
) -> None:
|
|
47
65
|
self.context = context
|
|
48
66
|
self.artifacts = artifacts
|
|
@@ -67,7 +85,9 @@ class WorkflowEngine:
|
|
|
67
85
|
statuses: dict[str, str] = {state.module.id: "pending" for state in states}
|
|
68
86
|
futures: dict[Future[StepResult], tuple[ModuleRuntimeState, StepConfig]] = {}
|
|
69
87
|
|
|
70
|
-
with ThreadPoolExecutor(
|
|
88
|
+
with ThreadPoolExecutor(
|
|
89
|
+
max_workers=max(1, self.context.config.llm.max_parallel)
|
|
90
|
+
) as pool:
|
|
71
91
|
while True:
|
|
72
92
|
for state in states:
|
|
73
93
|
if state.status in {"completed", "failed"}:
|
|
@@ -82,9 +102,14 @@ class WorkflowEngine:
|
|
|
82
102
|
continue
|
|
83
103
|
if futures and not step.is_read_only:
|
|
84
104
|
continue
|
|
85
|
-
if any(
|
|
105
|
+
if any(
|
|
106
|
+
not running_step.is_read_only
|
|
107
|
+
for _future, (_state, running_step) in futures.items()
|
|
108
|
+
):
|
|
86
109
|
continue
|
|
87
|
-
if step.is_read_only and len(futures) >= max(
|
|
110
|
+
if step.is_read_only and len(futures) >= max(
|
|
111
|
+
1, self.context.config.llm.max_parallel
|
|
112
|
+
):
|
|
88
113
|
continue
|
|
89
114
|
future = pool.submit(self._execute_step, state, step)
|
|
90
115
|
futures[future] = (state, step)
|
|
@@ -96,8 +121,15 @@ class WorkflowEngine:
|
|
|
96
121
|
if not futures:
|
|
97
122
|
if all(state.status == "completed" for state in states):
|
|
98
123
|
break
|
|
99
|
-
pending = [
|
|
100
|
-
|
|
124
|
+
pending = [
|
|
125
|
+
state.module.id
|
|
126
|
+
for state in states
|
|
127
|
+
if state.status not in {"completed", "failed"}
|
|
128
|
+
]
|
|
129
|
+
raise HookError(
|
|
130
|
+
"Scheduler deadlock while running modules: "
|
|
131
|
+
+ ", ".join(pending)
|
|
132
|
+
)
|
|
101
133
|
|
|
102
134
|
done, _ = wait(set(futures), return_when=FIRST_COMPLETED)
|
|
103
135
|
for future in done:
|
|
@@ -105,7 +137,7 @@ class WorkflowEngine:
|
|
|
105
137
|
state.active_step_id = None
|
|
106
138
|
try:
|
|
107
139
|
result = future.result()
|
|
108
|
-
except Exception as exc:
|
|
140
|
+
except Exception as exc:
|
|
109
141
|
state.status = "failed"
|
|
110
142
|
state.error = str(exc)
|
|
111
143
|
raise
|
|
@@ -126,8 +158,10 @@ class WorkflowEngine:
|
|
|
126
158
|
def _execute_step(self, state: ModuleRuntimeState, step: StepConfig) -> StepResult:
|
|
127
159
|
if step.when_env and env_bool(step.when_env) is not True:
|
|
128
160
|
payload = {"skipped": True, "reason": f"{step.when_env} not enabled"}
|
|
129
|
-
|
|
130
|
-
|
|
161
|
+
self.artifacts.write_json(
|
|
162
|
+
state, state.step_index, step.id, "result.json", payload
|
|
163
|
+
)
|
|
164
|
+
return StepResult()
|
|
131
165
|
|
|
132
166
|
if step.type == "collect":
|
|
133
167
|
if step.python:
|
|
@@ -138,24 +172,36 @@ class WorkflowEngine:
|
|
|
138
172
|
return self._persist_plugin_collect(state, step, result)
|
|
139
173
|
return self._run_collect(state, step)
|
|
140
174
|
|
|
141
|
-
input_paths = [
|
|
175
|
+
input_paths = [
|
|
176
|
+
self.artifacts.resolve_input(state, reference) for reference in step.inputs
|
|
177
|
+
]
|
|
142
178
|
stage_name = f"{state.module.id}.{step.id}"
|
|
143
179
|
|
|
144
180
|
if step.type == "ask":
|
|
145
181
|
prompt = resolve_prompt_text(self.context.repo_root, step)
|
|
146
|
-
payload = self.ask_executor(
|
|
182
|
+
payload = self.ask_executor(
|
|
183
|
+
self.context, step, prompt, input_paths, stage_name
|
|
184
|
+
)
|
|
147
185
|
artifact_name = step.output or "result.json"
|
|
148
186
|
if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
|
|
149
|
-
|
|
187
|
+
self.artifacts.write_json(
|
|
188
|
+
state, state.step_index, step.id, artifact_name, payload
|
|
189
|
+
)
|
|
150
190
|
else:
|
|
151
|
-
|
|
152
|
-
|
|
191
|
+
self.artifacts.write_text(
|
|
192
|
+
state, state.step_index, step.id, artifact_name, str(payload)
|
|
193
|
+
)
|
|
194
|
+
return StepResult()
|
|
153
195
|
|
|
154
196
|
if step.type == "apply":
|
|
155
197
|
prompt = resolve_prompt_text(self.context.repo_root, step)
|
|
156
|
-
payload = self.apply_executor(
|
|
157
|
-
|
|
158
|
-
|
|
198
|
+
payload = self.apply_executor(
|
|
199
|
+
self.context, state, step, prompt, input_paths, stage_name
|
|
200
|
+
)
|
|
201
|
+
self.artifacts.write_json(
|
|
202
|
+
state, state.step_index, step.id, "result.json", payload
|
|
203
|
+
)
|
|
204
|
+
return StepResult()
|
|
159
205
|
|
|
160
206
|
if step.type == "exec":
|
|
161
207
|
if step.python:
|
|
@@ -163,23 +209,25 @@ class WorkflowEngine:
|
|
|
163
209
|
payload = validate_exec_result(
|
|
164
210
|
self._dispatch_plugin(state, step, plugin_inputs)
|
|
165
211
|
)
|
|
166
|
-
|
|
167
|
-
return StepResult(
|
|
212
|
+
self._persist_plugin_result(state, step, payload)
|
|
213
|
+
return StepResult()
|
|
168
214
|
if step.command:
|
|
169
|
-
|
|
215
|
+
execute_step_command(
|
|
170
216
|
self.context,
|
|
171
217
|
state,
|
|
172
218
|
step,
|
|
173
219
|
dict(zip(step.inputs, input_paths)),
|
|
174
220
|
artifacts=self.artifacts,
|
|
175
221
|
)
|
|
176
|
-
return StepResult(
|
|
222
|
+
return StepResult()
|
|
177
223
|
handler = self.exec_handlers.get(step.executor or "")
|
|
178
224
|
if handler is None:
|
|
179
225
|
raise HookError(f"Unknown exec handler: {step.executor}")
|
|
180
226
|
payload = handler(self.context, state, step, input_paths)
|
|
181
|
-
|
|
182
|
-
|
|
227
|
+
self.artifacts.write_json(
|
|
228
|
+
state, state.step_index, step.id, "result.json", payload
|
|
229
|
+
)
|
|
230
|
+
return StepResult()
|
|
183
231
|
|
|
184
232
|
if step.type == "assert":
|
|
185
233
|
if step.python:
|
|
@@ -187,27 +235,29 @@ class WorkflowEngine:
|
|
|
187
235
|
payload = validate_assert_result(
|
|
188
236
|
self._dispatch_plugin(state, step, plugin_inputs)
|
|
189
237
|
)
|
|
190
|
-
|
|
238
|
+
self._persist_plugin_result(state, step, payload)
|
|
191
239
|
if not payload["ok"]:
|
|
192
240
|
raise HookError(payload.get("message", "assertion failed"))
|
|
193
|
-
return StepResult(
|
|
241
|
+
return StepResult()
|
|
194
242
|
if step.command:
|
|
195
|
-
|
|
243
|
+
execute_step_command(
|
|
196
244
|
self.context,
|
|
197
245
|
state,
|
|
198
246
|
step,
|
|
199
247
|
dict(zip(step.inputs, input_paths)),
|
|
200
248
|
artifacts=self.artifacts,
|
|
201
249
|
)
|
|
202
|
-
return StepResult(
|
|
250
|
+
return StepResult()
|
|
203
251
|
handler = self.assertion_handlers.get(step.assertion or "")
|
|
204
252
|
if handler is None:
|
|
205
253
|
raise HookError(f"Unknown assertion handler: {step.assertion}")
|
|
206
254
|
payload = handler(self.context, step, input_paths)
|
|
207
|
-
|
|
255
|
+
self.artifacts.write_json(
|
|
256
|
+
state, state.step_index, step.id, "result.json", payload
|
|
257
|
+
)
|
|
208
258
|
if not bool(payload.get("ok", False)):
|
|
209
259
|
raise HookError(str(payload.get("message", "assertion failed")))
|
|
210
|
-
return StepResult(
|
|
260
|
+
return StepResult()
|
|
211
261
|
|
|
212
262
|
raise HookError(f"Unsupported step type: {step.type}")
|
|
213
263
|
|
|
@@ -237,16 +287,15 @@ class WorkflowEngine:
|
|
|
237
287
|
) -> StepResult:
|
|
238
288
|
# Serialize and enforce both limits before the first write/register.
|
|
239
289
|
serialized = self.artifacts.serialize_plugin_artifacts(result.artifacts)
|
|
240
|
-
artifacts: dict[str, pathlib.Path] = {}
|
|
241
290
|
for artifact_name, content in serialized.items():
|
|
242
|
-
|
|
291
|
+
self.artifacts.write_bytes(
|
|
243
292
|
state, state.step_index, step.id, artifact_name, content
|
|
244
293
|
)
|
|
245
294
|
metadata = dict(result.metadata)
|
|
246
295
|
if result.skip_module:
|
|
247
296
|
metadata["skip_module"] = True
|
|
248
297
|
metadata["skip_reason"] = result.skip_reason
|
|
249
|
-
return StepResult(
|
|
298
|
+
return StepResult(metadata=metadata)
|
|
250
299
|
|
|
251
300
|
def _persist_plugin_result(
|
|
252
301
|
self, state: ModuleRuntimeState, step: StepConfig, payload: dict[str, Any]
|
|
@@ -264,15 +313,17 @@ class WorkflowEngine:
|
|
|
264
313
|
if handler is None:
|
|
265
314
|
raise HookError(f"Unknown collector: {step.collector}")
|
|
266
315
|
result = handler(self.context, state)
|
|
267
|
-
artifacts: dict[str, pathlib.Path] = {}
|
|
268
316
|
for artifact_name, payload in result.artifacts.items():
|
|
269
317
|
if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
|
|
270
|
-
|
|
318
|
+
self.artifacts.write_json(
|
|
319
|
+
state, state.step_index, step.id, artifact_name, payload
|
|
320
|
+
)
|
|
271
321
|
else:
|
|
272
|
-
|
|
273
|
-
|
|
322
|
+
self.artifacts.write_text(
|
|
323
|
+
state, state.step_index, step.id, artifact_name, str(payload)
|
|
324
|
+
)
|
|
274
325
|
metadata = dict(result.metadata)
|
|
275
326
|
if result.skip_module:
|
|
276
327
|
metadata["skip_module"] = True
|
|
277
328
|
metadata["skip_reason"] = result.skip_reason
|
|
278
|
-
return StepResult(
|
|
329
|
+
return StepResult(metadata=metadata)
|
|
@@ -21,15 +21,15 @@ from ..paths import (
|
|
|
21
21
|
sanitize_file_mode,
|
|
22
22
|
)
|
|
23
23
|
from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
|
|
24
|
-
from
|
|
24
|
+
from ..git_utils import (
|
|
25
25
|
list_repo_changes,
|
|
26
26
|
path_matches,
|
|
27
27
|
resolve_git_common_dir,
|
|
28
28
|
resolve_git_dir,
|
|
29
29
|
run_command,
|
|
30
30
|
)
|
|
31
|
-
from .ask import validate_opencode_attachments
|
|
32
31
|
from .runner_workflow import run_runner_once
|
|
32
|
+
from .runners.opencode_support import validate_hook_owned_artifacts
|
|
33
33
|
|
|
34
34
|
METADATA_MAX_FILES = 20_000
|
|
35
35
|
METADATA_MAX_BYTES = 64 * 1024 * 1024
|
|
@@ -68,8 +68,11 @@ class ApplyOperation:
|
|
|
68
68
|
|
|
69
69
|
def _is_protected_path(path: str) -> bool:
|
|
70
70
|
parts = pathlib.PurePosixPath(path).parts
|
|
71
|
-
return any(
|
|
72
|
-
|
|
71
|
+
return any(
|
|
72
|
+
normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts
|
|
73
|
+
) or (
|
|
74
|
+
bool(parts)
|
|
75
|
+
and normalized_component(parts[-1]) == PROTECTED_INSTRUCTION_FILENAME
|
|
73
76
|
)
|
|
74
77
|
|
|
75
78
|
|
|
@@ -102,7 +105,9 @@ def _hash_file(path: pathlib.Path, max_bytes: int | None = None) -> str:
|
|
|
102
105
|
break
|
|
103
106
|
total_bytes += len(chunk)
|
|
104
107
|
if max_bytes is not None and total_bytes > max_bytes:
|
|
105
|
-
raise HookError(
|
|
108
|
+
raise HookError(
|
|
109
|
+
f"File grew beyond bounded read budget while reading: {path}"
|
|
110
|
+
)
|
|
106
111
|
digest.update(chunk)
|
|
107
112
|
return digest.hexdigest()
|
|
108
113
|
|
|
@@ -116,11 +121,15 @@ def _read_regular_file(
|
|
|
116
121
|
raise HookError(f"File exceeds bounded read budget before reading: {path}")
|
|
117
122
|
content = handle.read() if max_bytes is None else handle.read(max_bytes + 1)
|
|
118
123
|
if max_bytes is not None and len(content) > max_bytes:
|
|
119
|
-
raise HookError(
|
|
124
|
+
raise HookError(
|
|
125
|
+
f"File grew beyond bounded read budget while reading: {path}"
|
|
126
|
+
)
|
|
120
127
|
return content, metadata.st_mode
|
|
121
128
|
|
|
122
129
|
|
|
123
|
-
def _snapshot_destination(
|
|
130
|
+
def _snapshot_destination(
|
|
131
|
+
repo_root: pathlib.Path, relative_path: str
|
|
132
|
+
) -> DestinationState:
|
|
124
133
|
destination = _repo_path_from_git(repo_root, relative_path)
|
|
125
134
|
if path_has_symlink(repo_root, destination):
|
|
126
135
|
return DestinationState("symlink")
|
|
@@ -143,15 +152,23 @@ def _repo_path_from_git(repo_root: pathlib.Path, path: str) -> pathlib.Path:
|
|
|
143
152
|
return repo_root.joinpath(*pure_path.parts)
|
|
144
153
|
|
|
145
154
|
|
|
146
|
-
def _snapshot_repo_files(
|
|
155
|
+
def _snapshot_repo_files(
|
|
156
|
+
repo_root: pathlib.Path, paths: set[str]
|
|
157
|
+
) -> dict[str, FileSnapshot]:
|
|
147
158
|
if len(paths) > STAGING_MAX_FILES:
|
|
148
|
-
raise HookError(
|
|
159
|
+
raise HookError(
|
|
160
|
+
"Git-visible checkout changes exceed the bounded safety snapshot budget"
|
|
161
|
+
)
|
|
149
162
|
snapshot: dict[str, FileSnapshot] = {}
|
|
150
163
|
total_bytes = 0
|
|
151
164
|
for path in paths:
|
|
152
165
|
full_path = _repo_path_from_git(repo_root, path)
|
|
153
166
|
if path_has_symlink(repo_root, full_path):
|
|
154
|
-
mode =
|
|
167
|
+
mode = (
|
|
168
|
+
full_path.lstat().st_mode
|
|
169
|
+
if full_path.exists() or full_path.is_symlink()
|
|
170
|
+
else None
|
|
171
|
+
)
|
|
155
172
|
snapshot[path] = (
|
|
156
173
|
"symlink",
|
|
157
174
|
mode,
|
|
@@ -205,12 +222,16 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
|
|
|
205
222
|
|
|
206
223
|
def excluded(path: pathlib.Path) -> bool:
|
|
207
224
|
lexical = pathlib.Path(os.path.abspath(path))
|
|
208
|
-
return any(
|
|
225
|
+
return any(
|
|
226
|
+
is_path_within(lexical, namespace) for namespace in excluded_namespaces
|
|
227
|
+
)
|
|
209
228
|
|
|
210
229
|
def record(key: str, path: pathlib.Path) -> None:
|
|
211
230
|
budget["entries"] += 1
|
|
212
231
|
if budget["entries"] > METADATA_MAX_FILES:
|
|
213
|
-
raise HookError(
|
|
232
|
+
raise HookError(
|
|
233
|
+
"Git control metadata exceeds the bounded safety snapshot budget"
|
|
234
|
+
)
|
|
214
235
|
if path_is_link_or_reparse(path):
|
|
215
236
|
raise HookError(
|
|
216
237
|
f"Refusing symlinked monitored Git metadata or reparse point: {key} ({path})"
|
|
@@ -282,7 +303,13 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
|
|
|
282
303
|
"refs",
|
|
283
304
|
"worktrees",
|
|
284
305
|
},
|
|
285
|
-
skipped_root_files={
|
|
306
|
+
skipped_root_files={
|
|
307
|
+
"HEAD",
|
|
308
|
+
"config",
|
|
309
|
+
"config.worktree",
|
|
310
|
+
"index",
|
|
311
|
+
"packed-refs",
|
|
312
|
+
},
|
|
286
313
|
)
|
|
287
314
|
record("current:logs/HEAD", common_dir / "logs" / "HEAD")
|
|
288
315
|
else:
|
|
@@ -318,11 +345,15 @@ def _git_index_state(repo_root: pathlib.Path) -> tuple[str, str]:
|
|
|
318
345
|
staged = run_command(
|
|
319
346
|
["git", "ls-files", "--stage", "-z"], cwd=repo_root, check=True
|
|
320
347
|
).stdout
|
|
321
|
-
flags = run_command(
|
|
348
|
+
flags = run_command(
|
|
349
|
+
["git", "ls-files", "-v", "-z"], cwd=repo_root, check=True
|
|
350
|
+
).stdout
|
|
322
351
|
return staged, flags
|
|
323
352
|
|
|
324
353
|
|
|
325
|
-
def _validate_apply_allowlist(
|
|
354
|
+
def _validate_apply_allowlist(
|
|
355
|
+
repo_root: pathlib.Path, patterns: tuple[str, ...]
|
|
356
|
+
) -> None:
|
|
326
357
|
for pattern in patterns:
|
|
327
358
|
parts = relative_path_parts(pattern, "Apply allow_paths entry")
|
|
328
359
|
if any(normalized_component(part) == PROTECTED_GIT_COMPONENT for part in parts):
|
|
@@ -337,7 +368,9 @@ def _validate_apply_allowlist(repo_root: pathlib.Path, patterns: tuple[str, ...]
|
|
|
337
368
|
if len(static_parts) == len(parts):
|
|
338
369
|
candidates = [repo_root.joinpath(*parts)]
|
|
339
370
|
else:
|
|
340
|
-
candidates =
|
|
371
|
+
candidates = (
|
|
372
|
+
[repo_root.joinpath(*static_parts)] if static_parts else [repo_root]
|
|
373
|
+
)
|
|
341
374
|
for candidate in candidates:
|
|
342
375
|
if path_has_symlink(repo_root, candidate):
|
|
343
376
|
raise HookError(f"Apply allow_paths traverses a symlink: {pattern}")
|
|
@@ -392,7 +425,9 @@ def _copy_checkout_to_staging(
|
|
|
392
425
|
)
|
|
393
426
|
resolved_source = source.resolve(strict=False)
|
|
394
427
|
if not is_path_within(resolved_source, resolved_repo_root):
|
|
395
|
-
raise HookError(
|
|
428
|
+
raise HookError(
|
|
429
|
+
f"Allowed checkout source escapes repository: {relative_path}"
|
|
430
|
+
)
|
|
396
431
|
if any(is_path_within(resolved_source, root) for root in git_roots):
|
|
397
432
|
continue
|
|
398
433
|
if not source.exists():
|
|
@@ -400,15 +435,21 @@ def _copy_checkout_to_staging(
|
|
|
400
435
|
if not stat.S_ISREG(source.lstat().st_mode):
|
|
401
436
|
if project_access == "project":
|
|
402
437
|
continue
|
|
403
|
-
raise HookError(
|
|
438
|
+
raise HookError(
|
|
439
|
+
f"Allowed checkout path is not a regular file: {relative_path}"
|
|
440
|
+
)
|
|
404
441
|
copied_files += 1
|
|
405
442
|
if copied_files > STAGING_MAX_FILES:
|
|
406
|
-
raise HookError(
|
|
443
|
+
raise HookError(
|
|
444
|
+
"Allowed apply files exceed the bounded staging workspace budget"
|
|
445
|
+
)
|
|
407
446
|
remaining_bytes = STAGING_MAX_BYTES - copied_bytes
|
|
408
447
|
content, source_mode = _read_regular_file(source, max_bytes=remaining_bytes)
|
|
409
448
|
copied_bytes += len(content)
|
|
410
449
|
if copied_bytes > STAGING_MAX_BYTES:
|
|
411
|
-
raise HookError(
|
|
450
|
+
raise HookError(
|
|
451
|
+
"Allowed apply files exceed the bounded staging workspace budget"
|
|
452
|
+
)
|
|
412
453
|
baselines[relative_path] = DestinationState(
|
|
413
454
|
"file",
|
|
414
455
|
source_mode,
|
|
@@ -427,7 +468,9 @@ def _inventory_staging(staging_root: pathlib.Path) -> dict[str, StagedFile]:
|
|
|
427
468
|
for directory, dirnames, filenames in os.walk(staging_root, followlinks=False):
|
|
428
469
|
total_entries += len(dirnames) + len(filenames)
|
|
429
470
|
if total_entries > STAGING_MAX_FILES:
|
|
430
|
-
raise HookError(
|
|
471
|
+
raise HookError(
|
|
472
|
+
"Apply staging workspace exceeds its bounded inventory budget"
|
|
473
|
+
)
|
|
431
474
|
directory_path = pathlib.Path(directory)
|
|
432
475
|
for name in dirnames:
|
|
433
476
|
path = directory_path / name
|
|
@@ -445,10 +488,14 @@ def _inventory_staging(staging_root: pathlib.Path) -> dict[str, StagedFile]:
|
|
|
445
488
|
f"Apply staging workspace contains symlink or reparse point: {relative}"
|
|
446
489
|
)
|
|
447
490
|
if not stat.S_ISREG(metadata.st_mode):
|
|
448
|
-
raise HookError(
|
|
491
|
+
raise HookError(
|
|
492
|
+
f"Apply staging workspace contains non-regular file: {relative}"
|
|
493
|
+
)
|
|
449
494
|
total_bytes += metadata.st_size
|
|
450
495
|
if len(inventory) >= STAGING_MAX_FILES or total_bytes > STAGING_MAX_BYTES:
|
|
451
|
-
raise HookError(
|
|
496
|
+
raise HookError(
|
|
497
|
+
"Apply staging workspace exceeds its bounded inventory budget"
|
|
498
|
+
)
|
|
452
499
|
inventory[relative] = StagedFile(
|
|
453
500
|
_hash_file(path, metadata.st_size), metadata.st_mode, metadata.st_size
|
|
454
501
|
)
|
|
@@ -471,7 +518,10 @@ def _changed_staging_paths(
|
|
|
471
518
|
)
|
|
472
519
|
)
|
|
473
520
|
if unexpected:
|
|
474
|
-
raise HookError(
|
|
521
|
+
raise HookError(
|
|
522
|
+
"Apply staging workspace contains paths outside allowlist: "
|
|
523
|
+
+ ", ".join(unexpected)
|
|
524
|
+
)
|
|
475
525
|
return {path for path in all_paths if before.get(path) != after.get(path)}
|
|
476
526
|
|
|
477
527
|
|
|
@@ -506,7 +556,9 @@ def _ignored_changed_paths(
|
|
|
506
556
|
def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Path:
|
|
507
557
|
repo_root = context.repo_root.resolve(strict=True)
|
|
508
558
|
if _is_protected_path(relative_path):
|
|
509
|
-
raise HookError(
|
|
559
|
+
raise HookError(
|
|
560
|
+
f"Apply destination must not contain Git metadata: {relative_path}"
|
|
561
|
+
)
|
|
510
562
|
destination = _repo_path_from_git(repo_root, relative_path)
|
|
511
563
|
if path_has_symlink(repo_root, destination):
|
|
512
564
|
raise HookError(f"Apply destination is or traverses a symlink: {relative_path}")
|
|
@@ -514,8 +566,12 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
|
|
|
514
566
|
while not existing_parent.exists() and existing_parent != repo_root:
|
|
515
567
|
existing_parent = existing_parent.parent
|
|
516
568
|
if not existing_parent.is_dir():
|
|
517
|
-
raise HookError(
|
|
518
|
-
|
|
569
|
+
raise HookError(
|
|
570
|
+
f"Apply destination has a non-directory parent: {relative_path}"
|
|
571
|
+
)
|
|
572
|
+
if not is_path_within(
|
|
573
|
+
existing_parent.resolve(strict=True), repo_root.resolve(strict=True)
|
|
574
|
+
):
|
|
519
575
|
raise HookError(f"Apply destination escapes repository: {relative_path}")
|
|
520
576
|
resolved_destination = destination.resolve(strict=False)
|
|
521
577
|
git_roots = (
|
|
@@ -523,7 +579,9 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
|
|
|
523
579
|
resolve_git_common_dir(context.repo_root).resolve(strict=True),
|
|
524
580
|
)
|
|
525
581
|
if any(is_path_within(resolved_destination, root) for root in git_roots):
|
|
526
|
-
raise HookError(
|
|
582
|
+
raise HookError(
|
|
583
|
+
f"Apply destination resolves inside Git metadata: {relative_path}"
|
|
584
|
+
)
|
|
527
585
|
return destination
|
|
528
586
|
|
|
529
587
|
|
|
@@ -547,16 +605,23 @@ def _preflight_apply_operations(
|
|
|
547
605
|
conflicts = [
|
|
548
606
|
operation.relative_path
|
|
549
607
|
for operation in operations
|
|
550
|
-
if _snapshot_destination(context.repo_root, operation.relative_path)
|
|
608
|
+
if _snapshot_destination(context.repo_root, operation.relative_path)
|
|
609
|
+
!= operation.baseline
|
|
551
610
|
]
|
|
552
611
|
if conflicts:
|
|
553
612
|
raise HookError(
|
|
554
|
-
"Apply checkout changed concurrently; refusing to overwrite: "
|
|
613
|
+
"Apply checkout changed concurrently; refusing to overwrite: "
|
|
614
|
+
+ ", ".join(conflicts)
|
|
555
615
|
)
|
|
556
616
|
|
|
557
617
|
|
|
558
|
-
def _verify_operation_baseline(
|
|
559
|
-
|
|
618
|
+
def _verify_operation_baseline(
|
|
619
|
+
context: RuntimeContext, operation: ApplyOperation
|
|
620
|
+
) -> None:
|
|
621
|
+
if (
|
|
622
|
+
_snapshot_destination(context.repo_root, operation.relative_path)
|
|
623
|
+
!= operation.baseline
|
|
624
|
+
):
|
|
560
625
|
raise HookError(
|
|
561
626
|
"Apply checkout changed concurrently; refusing to overwrite: "
|
|
562
627
|
+ operation.relative_path
|
|
@@ -575,7 +640,10 @@ def _propagate_staging_changes(
|
|
|
575
640
|
_ignored_changed_paths(context.repo_root, changed_paths, work_tree=staging_root)
|
|
576
641
|
)
|
|
577
642
|
if ignored:
|
|
578
|
-
raise HookError(
|
|
643
|
+
raise HookError(
|
|
644
|
+
"Refusing to copy staging output to ignored paths: "
|
|
645
|
+
+ ", ".join(sorted(ignored))
|
|
646
|
+
)
|
|
579
647
|
operations: list[ApplyOperation] = []
|
|
580
648
|
expected: dict[str, StagedFile | None] = {}
|
|
581
649
|
for relative_path in sorted(changed_paths):
|
|
@@ -598,12 +666,13 @@ def _propagate_staging_changes(
|
|
|
598
666
|
if not is_path_within(
|
|
599
667
|
source.resolve(strict=True), staging_root.resolve(strict=True)
|
|
600
668
|
):
|
|
601
|
-
raise HookError(
|
|
669
|
+
raise HookError(
|
|
670
|
+
f"Refusing staged output that escapes workspace: {relative_path}"
|
|
671
|
+
)
|
|
602
672
|
content, source_mode = _read_regular_file(source, max_bytes=staged.size)
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
):
|
|
673
|
+
if hashlib.sha256(content).hexdigest() != staged.digest or stat.S_IMODE(
|
|
674
|
+
source_mode
|
|
675
|
+
) != stat.S_IMODE(staged.mode):
|
|
607
676
|
raise HookError(f"Staged output changed after validation: {relative_path}")
|
|
608
677
|
approved_mode = _conservative_propagation_mode(baseline, staged.mode)
|
|
609
678
|
operations.append(
|
|
@@ -649,13 +718,14 @@ def _verify_propagated_changes(
|
|
|
649
718
|
mismatches.append(relative_path)
|
|
650
719
|
continue
|
|
651
720
|
metadata = destination.lstat()
|
|
652
|
-
if _hash_file(destination, staged.size) != staged.digest or stat.S_IMODE(
|
|
653
|
-
|
|
654
|
-
):
|
|
721
|
+
if _hash_file(destination, staged.size) != staged.digest or stat.S_IMODE(
|
|
722
|
+
metadata.st_mode
|
|
723
|
+
) != stat.S_IMODE(staged.mode):
|
|
655
724
|
mismatches.append(relative_path)
|
|
656
725
|
if mismatches:
|
|
657
726
|
raise HookError(
|
|
658
|
-
"Real checkout does not match validated staging output: "
|
|
727
|
+
"Real checkout does not match validated staging output: "
|
|
728
|
+
+ ", ".join(mismatches)
|
|
659
729
|
)
|
|
660
730
|
|
|
661
731
|
|
|
@@ -815,7 +885,7 @@ def run_apply_step(
|
|
|
815
885
|
input_paths: list[pathlib.Path],
|
|
816
886
|
stage_name: str,
|
|
817
887
|
) -> dict[str, object]:
|
|
818
|
-
validated_inputs =
|
|
888
|
+
validated_inputs = validate_hook_owned_artifacts(context, input_paths)
|
|
819
889
|
for input_path in validated_inputs:
|
|
820
890
|
if input_path.name.endswith("issues.json"):
|
|
821
891
|
issues = json.loads(input_path.read_text(encoding="utf-8"))
|
|
@@ -834,7 +904,9 @@ def run_apply_step(
|
|
|
834
904
|
call_error: Exception | None = None
|
|
835
905
|
staged_changes: set[str] = set()
|
|
836
906
|
propagated_expected: dict[str, StagedFile | None] = {}
|
|
837
|
-
with tempfile.TemporaryDirectory(
|
|
907
|
+
with tempfile.TemporaryDirectory(
|
|
908
|
+
prefix="ai-push-hooks-apply-"
|
|
909
|
+
) as temporary_directory:
|
|
838
910
|
staging_root = pathlib.Path(temporary_directory).resolve(strict=True)
|
|
839
911
|
profile = resolve_runner_profile(context.config, step)
|
|
840
912
|
destination_baselines = _copy_checkout_to_staging(
|
|
@@ -877,11 +949,17 @@ def run_apply_step(
|
|
|
877
949
|
metadata_before,
|
|
878
950
|
)
|
|
879
951
|
if call_error is not None:
|
|
880
|
-
raise HookError(
|
|
952
|
+
raise HookError(
|
|
953
|
+
f"Apply step failed in isolated staging: {call_error}"
|
|
954
|
+
) from call_error
|
|
881
955
|
if result is None:
|
|
882
956
|
raise HookError("Apply step failed without a runner result")
|
|
883
957
|
if result.returncode != 0:
|
|
884
|
-
details =
|
|
958
|
+
details = (
|
|
959
|
+
result.stderr.strip()
|
|
960
|
+
or result.stdout.strip()
|
|
961
|
+
or f"exit code {result.returncode}"
|
|
962
|
+
)
|
|
885
963
|
raise HookError(f"Apply step failed in isolated staging: {details}")
|
|
886
964
|
propagated_expected = _propagate_staging_changes(
|
|
887
965
|
context,
|