ai-push-hooks 0.3.1 → 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 +49 -1
- package/README.md +91 -80
- package/ai-push-hooks.toml +1 -1
- package/docs/configuration.md +263 -0
- package/package.json +3 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -6
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +220 -49
- package/src/ai_push_hooks/engine.py +93 -43
- package/src/ai_push_hooks/executors/apply.py +121 -43
- package/src/ai_push_hooks/executors/ask.py +30 -13
- package/src/ai_push_hooks/executors/exec.py +61 -22
- package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
- 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 +74 -21
- package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
- package/src/ai_push_hooks/executors/runners/process.py +26 -7
- package/src/ai_push_hooks/executors/runners/registry.py +14 -5
- package/src/ai_push_hooks/executors/step_commands.py +58 -18
- package/src/ai_push_hooks/git_utils.py +92 -27
- package/src/ai_push_hooks/hook.py +48 -12
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +18 -7
- package/src/ai_push_hooks/modules/docs.py +17 -7
- package/src/ai_push_hooks/modules/pr.py +18 -7
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +79 -26
- 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 +47 -27
- package/run.sh +0 -29
|
@@ -1,17 +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
|
|
11
|
+
from .executors.ask import run_ask_step
|
|
10
12
|
from .executors.assertions import ASSERTION_HANDLERS
|
|
11
13
|
from .executors.exec import EXEC_HANDLERS
|
|
12
|
-
from .git_utils import env_bool
|
|
13
|
-
from .executors.ask import run_ask_step
|
|
14
14
|
from .executors.step_commands import execute_step_command
|
|
15
|
+
from .git_utils import env_bool
|
|
15
16
|
from .modules import COLLECTORS
|
|
16
17
|
from .plugin_loader import PluginDispatcher
|
|
17
18
|
from .plugins import (
|
|
@@ -30,8 +31,12 @@ from .types import (
|
|
|
30
31
|
)
|
|
31
32
|
|
|
32
33
|
CollectorHandler = Callable[[RuntimeContext, ModuleRuntimeState], CollectorResult]
|
|
33
|
-
ExecHandler = Callable[
|
|
34
|
-
|
|
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
|
+
]
|
|
35
40
|
|
|
36
41
|
|
|
37
42
|
class WorkflowEngine:
|
|
@@ -42,8 +47,20 @@ class WorkflowEngine:
|
|
|
42
47
|
collectors: dict[str, CollectorHandler] | None = None,
|
|
43
48
|
exec_handlers: dict[str, ExecHandler] | None = None,
|
|
44
49
|
assertion_handlers: dict[str, AssertionHandler] | None = None,
|
|
45
|
-
ask_executor: Callable[
|
|
46
|
-
|
|
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,
|
|
47
64
|
) -> None:
|
|
48
65
|
self.context = context
|
|
49
66
|
self.artifacts = artifacts
|
|
@@ -68,7 +85,9 @@ class WorkflowEngine:
|
|
|
68
85
|
statuses: dict[str, str] = {state.module.id: "pending" for state in states}
|
|
69
86
|
futures: dict[Future[StepResult], tuple[ModuleRuntimeState, StepConfig]] = {}
|
|
70
87
|
|
|
71
|
-
with ThreadPoolExecutor(
|
|
88
|
+
with ThreadPoolExecutor(
|
|
89
|
+
max_workers=max(1, self.context.config.llm.max_parallel)
|
|
90
|
+
) as pool:
|
|
72
91
|
while True:
|
|
73
92
|
for state in states:
|
|
74
93
|
if state.status in {"completed", "failed"}:
|
|
@@ -83,9 +102,14 @@ class WorkflowEngine:
|
|
|
83
102
|
continue
|
|
84
103
|
if futures and not step.is_read_only:
|
|
85
104
|
continue
|
|
86
|
-
if any(
|
|
105
|
+
if any(
|
|
106
|
+
not running_step.is_read_only
|
|
107
|
+
for _future, (_state, running_step) in futures.items()
|
|
108
|
+
):
|
|
87
109
|
continue
|
|
88
|
-
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
|
+
):
|
|
89
113
|
continue
|
|
90
114
|
future = pool.submit(self._execute_step, state, step)
|
|
91
115
|
futures[future] = (state, step)
|
|
@@ -97,8 +121,15 @@ class WorkflowEngine:
|
|
|
97
121
|
if not futures:
|
|
98
122
|
if all(state.status == "completed" for state in states):
|
|
99
123
|
break
|
|
100
|
-
pending = [
|
|
101
|
-
|
|
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
|
+
)
|
|
102
133
|
|
|
103
134
|
done, _ = wait(set(futures), return_when=FIRST_COMPLETED)
|
|
104
135
|
for future in done:
|
|
@@ -106,7 +137,7 @@ class WorkflowEngine:
|
|
|
106
137
|
state.active_step_id = None
|
|
107
138
|
try:
|
|
108
139
|
result = future.result()
|
|
109
|
-
except Exception as exc:
|
|
140
|
+
except Exception as exc:
|
|
110
141
|
state.status = "failed"
|
|
111
142
|
state.error = str(exc)
|
|
112
143
|
raise
|
|
@@ -127,8 +158,10 @@ class WorkflowEngine:
|
|
|
127
158
|
def _execute_step(self, state: ModuleRuntimeState, step: StepConfig) -> StepResult:
|
|
128
159
|
if step.when_env and env_bool(step.when_env) is not True:
|
|
129
160
|
payload = {"skipped": True, "reason": f"{step.when_env} not enabled"}
|
|
130
|
-
|
|
131
|
-
|
|
161
|
+
self.artifacts.write_json(
|
|
162
|
+
state, state.step_index, step.id, "result.json", payload
|
|
163
|
+
)
|
|
164
|
+
return StepResult()
|
|
132
165
|
|
|
133
166
|
if step.type == "collect":
|
|
134
167
|
if step.python:
|
|
@@ -139,24 +172,36 @@ class WorkflowEngine:
|
|
|
139
172
|
return self._persist_plugin_collect(state, step, result)
|
|
140
173
|
return self._run_collect(state, step)
|
|
141
174
|
|
|
142
|
-
input_paths = [
|
|
175
|
+
input_paths = [
|
|
176
|
+
self.artifacts.resolve_input(state, reference) for reference in step.inputs
|
|
177
|
+
]
|
|
143
178
|
stage_name = f"{state.module.id}.{step.id}"
|
|
144
179
|
|
|
145
180
|
if step.type == "ask":
|
|
146
181
|
prompt = resolve_prompt_text(self.context.repo_root, step)
|
|
147
|
-
payload = self.ask_executor(
|
|
182
|
+
payload = self.ask_executor(
|
|
183
|
+
self.context, step, prompt, input_paths, stage_name
|
|
184
|
+
)
|
|
148
185
|
artifact_name = step.output or "result.json"
|
|
149
186
|
if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
|
|
150
|
-
|
|
187
|
+
self.artifacts.write_json(
|
|
188
|
+
state, state.step_index, step.id, artifact_name, payload
|
|
189
|
+
)
|
|
151
190
|
else:
|
|
152
|
-
|
|
153
|
-
|
|
191
|
+
self.artifacts.write_text(
|
|
192
|
+
state, state.step_index, step.id, artifact_name, str(payload)
|
|
193
|
+
)
|
|
194
|
+
return StepResult()
|
|
154
195
|
|
|
155
196
|
if step.type == "apply":
|
|
156
197
|
prompt = resolve_prompt_text(self.context.repo_root, step)
|
|
157
|
-
payload = self.apply_executor(
|
|
158
|
-
|
|
159
|
-
|
|
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()
|
|
160
205
|
|
|
161
206
|
if step.type == "exec":
|
|
162
207
|
if step.python:
|
|
@@ -164,23 +209,25 @@ class WorkflowEngine:
|
|
|
164
209
|
payload = validate_exec_result(
|
|
165
210
|
self._dispatch_plugin(state, step, plugin_inputs)
|
|
166
211
|
)
|
|
167
|
-
|
|
168
|
-
return StepResult(
|
|
212
|
+
self._persist_plugin_result(state, step, payload)
|
|
213
|
+
return StepResult()
|
|
169
214
|
if step.command:
|
|
170
|
-
|
|
215
|
+
execute_step_command(
|
|
171
216
|
self.context,
|
|
172
217
|
state,
|
|
173
218
|
step,
|
|
174
219
|
dict(zip(step.inputs, input_paths)),
|
|
175
220
|
artifacts=self.artifacts,
|
|
176
221
|
)
|
|
177
|
-
return StepResult(
|
|
222
|
+
return StepResult()
|
|
178
223
|
handler = self.exec_handlers.get(step.executor or "")
|
|
179
224
|
if handler is None:
|
|
180
225
|
raise HookError(f"Unknown exec handler: {step.executor}")
|
|
181
226
|
payload = handler(self.context, state, step, input_paths)
|
|
182
|
-
|
|
183
|
-
|
|
227
|
+
self.artifacts.write_json(
|
|
228
|
+
state, state.step_index, step.id, "result.json", payload
|
|
229
|
+
)
|
|
230
|
+
return StepResult()
|
|
184
231
|
|
|
185
232
|
if step.type == "assert":
|
|
186
233
|
if step.python:
|
|
@@ -188,27 +235,29 @@ class WorkflowEngine:
|
|
|
188
235
|
payload = validate_assert_result(
|
|
189
236
|
self._dispatch_plugin(state, step, plugin_inputs)
|
|
190
237
|
)
|
|
191
|
-
|
|
238
|
+
self._persist_plugin_result(state, step, payload)
|
|
192
239
|
if not payload["ok"]:
|
|
193
240
|
raise HookError(payload.get("message", "assertion failed"))
|
|
194
|
-
return StepResult(
|
|
241
|
+
return StepResult()
|
|
195
242
|
if step.command:
|
|
196
|
-
|
|
243
|
+
execute_step_command(
|
|
197
244
|
self.context,
|
|
198
245
|
state,
|
|
199
246
|
step,
|
|
200
247
|
dict(zip(step.inputs, input_paths)),
|
|
201
248
|
artifacts=self.artifacts,
|
|
202
249
|
)
|
|
203
|
-
return StepResult(
|
|
250
|
+
return StepResult()
|
|
204
251
|
handler = self.assertion_handlers.get(step.assertion or "")
|
|
205
252
|
if handler is None:
|
|
206
253
|
raise HookError(f"Unknown assertion handler: {step.assertion}")
|
|
207
254
|
payload = handler(self.context, step, input_paths)
|
|
208
|
-
|
|
255
|
+
self.artifacts.write_json(
|
|
256
|
+
state, state.step_index, step.id, "result.json", payload
|
|
257
|
+
)
|
|
209
258
|
if not bool(payload.get("ok", False)):
|
|
210
259
|
raise HookError(str(payload.get("message", "assertion failed")))
|
|
211
|
-
return StepResult(
|
|
260
|
+
return StepResult()
|
|
212
261
|
|
|
213
262
|
raise HookError(f"Unsupported step type: {step.type}")
|
|
214
263
|
|
|
@@ -238,16 +287,15 @@ class WorkflowEngine:
|
|
|
238
287
|
) -> StepResult:
|
|
239
288
|
# Serialize and enforce both limits before the first write/register.
|
|
240
289
|
serialized = self.artifacts.serialize_plugin_artifacts(result.artifacts)
|
|
241
|
-
artifacts: dict[str, pathlib.Path] = {}
|
|
242
290
|
for artifact_name, content in serialized.items():
|
|
243
|
-
|
|
291
|
+
self.artifacts.write_bytes(
|
|
244
292
|
state, state.step_index, step.id, artifact_name, content
|
|
245
293
|
)
|
|
246
294
|
metadata = dict(result.metadata)
|
|
247
295
|
if result.skip_module:
|
|
248
296
|
metadata["skip_module"] = True
|
|
249
297
|
metadata["skip_reason"] = result.skip_reason
|
|
250
|
-
return StepResult(
|
|
298
|
+
return StepResult(metadata=metadata)
|
|
251
299
|
|
|
252
300
|
def _persist_plugin_result(
|
|
253
301
|
self, state: ModuleRuntimeState, step: StepConfig, payload: dict[str, Any]
|
|
@@ -265,15 +313,17 @@ class WorkflowEngine:
|
|
|
265
313
|
if handler is None:
|
|
266
314
|
raise HookError(f"Unknown collector: {step.collector}")
|
|
267
315
|
result = handler(self.context, state)
|
|
268
|
-
artifacts: dict[str, pathlib.Path] = {}
|
|
269
316
|
for artifact_name, payload in result.artifacts.items():
|
|
270
317
|
if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
|
|
271
|
-
|
|
318
|
+
self.artifacts.write_json(
|
|
319
|
+
state, state.step_index, step.id, artifact_name, payload
|
|
320
|
+
)
|
|
272
321
|
else:
|
|
273
|
-
|
|
274
|
-
|
|
322
|
+
self.artifacts.write_text(
|
|
323
|
+
state, state.step_index, step.id, artifact_name, str(payload)
|
|
324
|
+
)
|
|
275
325
|
metadata = dict(result.metadata)
|
|
276
326
|
if result.skip_module:
|
|
277
327
|
metadata["skip_module"] = True
|
|
278
328
|
metadata["skip_reason"] = result.skip_reason
|
|
279
|
-
return StepResult(
|
|
329
|
+
return StepResult(metadata=metadata)
|
|
@@ -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
|
|
|
@@ -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,
|