ai-push-hooks 0.2.0 → 0.3.0
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 +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- 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 +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -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 +406 -75
|
@@ -9,9 +9,24 @@ from .config import resolve_prompt_text
|
|
|
9
9
|
from .executors.apply import run_apply_step
|
|
10
10
|
from .executors.assertions import ASSERTION_HANDLERS
|
|
11
11
|
from .executors.exec import EXEC_HANDLERS, env_bool
|
|
12
|
-
from .executors.
|
|
12
|
+
from .executors.ask import run_ask_step
|
|
13
|
+
from .executors.step_commands import execute_step_command
|
|
13
14
|
from .modules import COLLECTORS
|
|
14
|
-
from .
|
|
15
|
+
from .plugin_loader import PluginDispatcher
|
|
16
|
+
from .plugins import (
|
|
17
|
+
validate_assert_result,
|
|
18
|
+
validate_collector_result,
|
|
19
|
+
validate_exec_result,
|
|
20
|
+
)
|
|
21
|
+
from .types import (
|
|
22
|
+
CollectorResult,
|
|
23
|
+
HookError,
|
|
24
|
+
ModuleRuntimeState,
|
|
25
|
+
RuntimeContext,
|
|
26
|
+
StepConfig,
|
|
27
|
+
StepResult,
|
|
28
|
+
WorkflowRunResult,
|
|
29
|
+
)
|
|
15
30
|
|
|
16
31
|
CollectorHandler = Callable[[RuntimeContext, ModuleRuntimeState], CollectorResult]
|
|
17
32
|
ExecHandler = Callable[[RuntimeContext, ModuleRuntimeState, StepConfig, list[pathlib.Path]], dict[str, Any]]
|
|
@@ -26,7 +41,7 @@ class WorkflowEngine:
|
|
|
26
41
|
collectors: dict[str, CollectorHandler] | None = None,
|
|
27
42
|
exec_handlers: dict[str, ExecHandler] | None = None,
|
|
28
43
|
assertion_handlers: dict[str, AssertionHandler] | None = None,
|
|
29
|
-
|
|
44
|
+
ask_executor: Callable[[RuntimeContext, StepConfig, str, list[pathlib.Path], str], Any] = run_ask_step,
|
|
30
45
|
apply_executor: Callable[[RuntimeContext, ModuleRuntimeState, StepConfig, str, list[pathlib.Path], str], dict[str, object]] = run_apply_step,
|
|
31
46
|
) -> None:
|
|
32
47
|
self.context = context
|
|
@@ -34,11 +49,16 @@ class WorkflowEngine:
|
|
|
34
49
|
self.collectors = collectors or COLLECTORS
|
|
35
50
|
self.exec_handlers = exec_handlers or EXEC_HANDLERS
|
|
36
51
|
self.assertion_handlers = assertion_handlers or ASSERTION_HANDLERS
|
|
37
|
-
self.
|
|
52
|
+
self.ask_executor = ask_executor
|
|
38
53
|
self.apply_executor = apply_executor
|
|
54
|
+
self._plugin_dispatcher: PluginDispatcher | None = None
|
|
39
55
|
|
|
40
56
|
def run(self) -> WorkflowRunResult:
|
|
41
57
|
self.artifacts.prepare()
|
|
58
|
+
# A loader/cache is deliberately scoped to one workflow run. In
|
|
59
|
+
# particular, a second run must not reuse a module snapshot from the
|
|
60
|
+
# first run.
|
|
61
|
+
self._plugin_dispatcher = PluginDispatcher()
|
|
42
62
|
states = [
|
|
43
63
|
ModuleRuntimeState(module=self.context.config.modules[module_id])
|
|
44
64
|
for module_id in self.context.config.workflow.modules
|
|
@@ -110,14 +130,20 @@ class WorkflowEngine:
|
|
|
110
130
|
return StepResult(status="skipped", artifacts={"result.json": path}, metadata={})
|
|
111
131
|
|
|
112
132
|
if step.type == "collect":
|
|
133
|
+
if step.python:
|
|
134
|
+
input_paths = self._resolve_plugin_inputs(state, step)
|
|
135
|
+
result = validate_collector_result(
|
|
136
|
+
self._dispatch_plugin(state, step, input_paths)
|
|
137
|
+
)
|
|
138
|
+
return self._persist_plugin_collect(state, step, result)
|
|
113
139
|
return self._run_collect(state, step)
|
|
114
140
|
|
|
115
141
|
input_paths = [self.artifacts.resolve_input(state, reference) for reference in step.inputs]
|
|
116
142
|
stage_name = f"{state.module.id}.{step.id}"
|
|
117
143
|
|
|
118
|
-
if step.type == "
|
|
144
|
+
if step.type == "ask":
|
|
119
145
|
prompt = resolve_prompt_text(self.context.repo_root, step)
|
|
120
|
-
payload = self.
|
|
146
|
+
payload = self.ask_executor(self.context, step, prompt, input_paths, stage_name)
|
|
121
147
|
artifact_name = step.output or "result.json"
|
|
122
148
|
if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
|
|
123
149
|
path = self.artifacts.write_json(state, state.step_index, step.id, artifact_name, payload)
|
|
@@ -132,6 +158,22 @@ class WorkflowEngine:
|
|
|
132
158
|
return StepResult(artifacts={"result.json": path})
|
|
133
159
|
|
|
134
160
|
if step.type == "exec":
|
|
161
|
+
if step.python:
|
|
162
|
+
plugin_inputs = dict(zip(step.inputs, input_paths))
|
|
163
|
+
payload = validate_exec_result(
|
|
164
|
+
self._dispatch_plugin(state, step, plugin_inputs)
|
|
165
|
+
)
|
|
166
|
+
path = self._persist_plugin_result(state, step, payload)
|
|
167
|
+
return StepResult(artifacts={"result.json": path})
|
|
168
|
+
if step.command:
|
|
169
|
+
persisted = execute_step_command(
|
|
170
|
+
self.context,
|
|
171
|
+
state,
|
|
172
|
+
step,
|
|
173
|
+
dict(zip(step.inputs, input_paths)),
|
|
174
|
+
artifacts=self.artifacts,
|
|
175
|
+
)
|
|
176
|
+
return StepResult(artifacts=dict(persisted.artifacts))
|
|
135
177
|
handler = self.exec_handlers.get(step.executor or "")
|
|
136
178
|
if handler is None:
|
|
137
179
|
raise HookError(f"Unknown exec handler: {step.executor}")
|
|
@@ -140,6 +182,24 @@ class WorkflowEngine:
|
|
|
140
182
|
return StepResult(artifacts={"result.json": path})
|
|
141
183
|
|
|
142
184
|
if step.type == "assert":
|
|
185
|
+
if step.python:
|
|
186
|
+
plugin_inputs = dict(zip(step.inputs, input_paths))
|
|
187
|
+
payload = validate_assert_result(
|
|
188
|
+
self._dispatch_plugin(state, step, plugin_inputs)
|
|
189
|
+
)
|
|
190
|
+
path = self._persist_plugin_result(state, step, payload)
|
|
191
|
+
if not payload["ok"]:
|
|
192
|
+
raise HookError(payload.get("message", "assertion failed"))
|
|
193
|
+
return StepResult(artifacts={"result.json": path})
|
|
194
|
+
if step.command:
|
|
195
|
+
persisted = execute_step_command(
|
|
196
|
+
self.context,
|
|
197
|
+
state,
|
|
198
|
+
step,
|
|
199
|
+
dict(zip(step.inputs, input_paths)),
|
|
200
|
+
artifacts=self.artifacts,
|
|
201
|
+
)
|
|
202
|
+
return StepResult(artifacts=dict(persisted.artifacts))
|
|
143
203
|
handler = self.assertion_handlers.get(step.assertion or "")
|
|
144
204
|
if handler is None:
|
|
145
205
|
raise HookError(f"Unknown assertion handler: {step.assertion}")
|
|
@@ -151,6 +211,54 @@ class WorkflowEngine:
|
|
|
151
211
|
|
|
152
212
|
raise HookError(f"Unsupported step type: {step.type}")
|
|
153
213
|
|
|
214
|
+
def _resolve_plugin_inputs(
|
|
215
|
+
self, state: ModuleRuntimeState, step: StepConfig
|
|
216
|
+
) -> dict[str, pathlib.Path]:
|
|
217
|
+
"""Resolve declared inputs in declaration order for a callback."""
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
reference: self.artifacts.resolve_input(state, reference)
|
|
221
|
+
for reference in step.inputs
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def _dispatch_plugin(
|
|
225
|
+
self,
|
|
226
|
+
state: ModuleRuntimeState,
|
|
227
|
+
step: StepConfig,
|
|
228
|
+
input_paths: dict[str, pathlib.Path],
|
|
229
|
+
) -> Any:
|
|
230
|
+
dispatcher = self._plugin_dispatcher
|
|
231
|
+
if dispatcher is None: # pragma: no cover - only direct private calls
|
|
232
|
+
dispatcher = PluginDispatcher()
|
|
233
|
+
return dispatcher.dispatch(self.context, state, step, input_paths)
|
|
234
|
+
|
|
235
|
+
def _persist_plugin_collect(
|
|
236
|
+
self, state: ModuleRuntimeState, step: StepConfig, result: CollectorResult
|
|
237
|
+
) -> StepResult:
|
|
238
|
+
# Serialize and enforce both limits before the first write/register.
|
|
239
|
+
serialized = self.artifacts.serialize_plugin_artifacts(result.artifacts)
|
|
240
|
+
artifacts: dict[str, pathlib.Path] = {}
|
|
241
|
+
for artifact_name, content in serialized.items():
|
|
242
|
+
artifacts[artifact_name] = self.artifacts.write_bytes(
|
|
243
|
+
state, state.step_index, step.id, artifact_name, content
|
|
244
|
+
)
|
|
245
|
+
metadata = dict(result.metadata)
|
|
246
|
+
if result.skip_module:
|
|
247
|
+
metadata["skip_module"] = True
|
|
248
|
+
metadata["skip_reason"] = result.skip_reason
|
|
249
|
+
return StepResult(artifacts=artifacts, metadata=metadata)
|
|
250
|
+
|
|
251
|
+
def _persist_plugin_result(
|
|
252
|
+
self, state: ModuleRuntimeState, step: StepConfig, payload: dict[str, Any]
|
|
253
|
+
) -> pathlib.Path:
|
|
254
|
+
# Use the same bounded serializer as collector artifacts. Validation
|
|
255
|
+
# happens first, and serialization happens before the result is written
|
|
256
|
+
# or registered.
|
|
257
|
+
serialized = self.artifacts.serialize_plugin_artifacts({"result.json": payload})
|
|
258
|
+
return self.artifacts.write_bytes(
|
|
259
|
+
state, state.step_index, step.id, "result.json", serialized["result.json"]
|
|
260
|
+
)
|
|
261
|
+
|
|
154
262
|
def _run_collect(self, state: ModuleRuntimeState, step: StepConfig) -> StepResult:
|
|
155
263
|
handler = self.collectors.get(step.collector or "")
|
|
156
264
|
if handler is None:
|
|
@@ -9,6 +9,7 @@ import tempfile
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
+
from ..config import resolve_runner_profile
|
|
12
13
|
from ..paths import (
|
|
13
14
|
atomic_write_bytes,
|
|
14
15
|
ensure_private_directory,
|
|
@@ -27,7 +28,8 @@ from .exec import (
|
|
|
27
28
|
resolve_git_dir,
|
|
28
29
|
run_command,
|
|
29
30
|
)
|
|
30
|
-
from .
|
|
31
|
+
from .ask import validate_opencode_attachments
|
|
32
|
+
from .runner_workflow import run_runner_once
|
|
31
33
|
|
|
32
34
|
METADATA_MAX_FILES = 20_000
|
|
33
35
|
METADATA_MAX_BYTES = 64 * 1024 * 1024
|
|
@@ -354,6 +356,8 @@ def _copy_checkout_to_staging(
|
|
|
354
356
|
context: RuntimeContext,
|
|
355
357
|
staging_root: pathlib.Path,
|
|
356
358
|
allow_paths: tuple[str, ...],
|
|
359
|
+
*,
|
|
360
|
+
project_access: str = "artifacts",
|
|
357
361
|
) -> dict[str, DestinationState]:
|
|
358
362
|
repo_root = context.repo_root
|
|
359
363
|
git_roots = (
|
|
@@ -364,16 +368,25 @@ def _copy_checkout_to_staging(
|
|
|
364
368
|
copied_bytes = 0
|
|
365
369
|
baselines: dict[str, DestinationState] = {}
|
|
366
370
|
resolved_repo_root = repo_root.resolve(strict=True)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
371
|
+
checkout_paths = _tracked_and_unignored_paths(repo_root)
|
|
372
|
+
if project_access == "project":
|
|
373
|
+
projection_candidates = checkout_paths
|
|
374
|
+
else:
|
|
375
|
+
projection_candidates = {
|
|
376
|
+
relative_path
|
|
377
|
+
for relative_path in checkout_paths
|
|
378
|
+
if not _is_protected_path(relative_path)
|
|
379
|
+
and any(path_matches(relative_path, pattern) for pattern in allow_paths)
|
|
380
|
+
}
|
|
381
|
+
ignored_paths = _ignored_changed_paths(repo_root, projection_candidates)
|
|
382
|
+
projection_paths = projection_candidates - ignored_paths
|
|
383
|
+
for relative_path in sorted(projection_paths):
|
|
384
|
+
if _is_protected_path(relative_path):
|
|
385
|
+
continue
|
|
375
386
|
source = _repo_path_from_git(repo_root, relative_path)
|
|
376
387
|
if path_has_symlink(repo_root, source):
|
|
388
|
+
if project_access == "project":
|
|
389
|
+
continue
|
|
377
390
|
raise HookError(
|
|
378
391
|
f"Allowed checkout path is or traverses a symlink or reparse point: {relative_path}"
|
|
379
392
|
)
|
|
@@ -384,7 +397,9 @@ def _copy_checkout_to_staging(
|
|
|
384
397
|
continue
|
|
385
398
|
if not source.exists():
|
|
386
399
|
continue
|
|
387
|
-
if not source.
|
|
400
|
+
if not stat.S_ISREG(source.lstat().st_mode):
|
|
401
|
+
if project_access == "project":
|
|
402
|
+
continue
|
|
388
403
|
raise HookError(f"Allowed checkout path is not a regular file: {relative_path}")
|
|
389
404
|
copied_files += 1
|
|
390
405
|
if copied_files > STAGING_MAX_FILES:
|
|
@@ -449,8 +464,11 @@ def _changed_staging_paths(
|
|
|
449
464
|
unexpected = sorted(
|
|
450
465
|
path
|
|
451
466
|
for path in all_paths
|
|
452
|
-
if
|
|
453
|
-
|
|
467
|
+
if before.get(path) != after.get(path)
|
|
468
|
+
and (
|
|
469
|
+
_is_protected_path(path)
|
|
470
|
+
or not any(path_matches(path, pattern) for pattern in allow_paths)
|
|
471
|
+
)
|
|
454
472
|
)
|
|
455
473
|
if unexpected:
|
|
456
474
|
raise HookError("Apply staging workspace contains paths outside allowlist: " + ", ".join(unexpected))
|
|
@@ -732,14 +750,33 @@ def _verify_post_propagation_security_state(
|
|
|
732
750
|
)
|
|
733
751
|
|
|
734
752
|
|
|
735
|
-
def _apply_prompt(
|
|
753
|
+
def _apply_prompt(
|
|
754
|
+
prompt: str,
|
|
755
|
+
allow_paths: tuple[str, ...],
|
|
756
|
+
*,
|
|
757
|
+
project_access: str = "artifacts",
|
|
758
|
+
) -> str:
|
|
736
759
|
rendered_paths = "\n".join(f"- {pattern}" for pattern in allow_paths)
|
|
760
|
+
if project_access == "project":
|
|
761
|
+
projection_text = (
|
|
762
|
+
"This workspace contains the eligible readable project projection and may "
|
|
763
|
+
"include readable repository files beyond the allowlist."
|
|
764
|
+
)
|
|
765
|
+
else:
|
|
766
|
+
projection_text = (
|
|
767
|
+
"This workspace contains only eligible readable files selected by the "
|
|
768
|
+
"allowlist."
|
|
769
|
+
)
|
|
737
770
|
return (
|
|
738
771
|
prompt.rstrip()
|
|
739
|
-
+ "\n\nMANDATORY STAGING
|
|
740
|
-
+
|
|
772
|
+
+ "\n\nMANDATORY STAGING BOUNDARY:\n"
|
|
773
|
+
+ projection_text
|
|
774
|
+
+ "\nOnly changes to paths matching the allowlist below propagate to the real "
|
|
775
|
+
"checkout. Any other staging change fails the apply step.\n"
|
|
776
|
+
+ "Allowlisted paths:\n"
|
|
741
777
|
+ rendered_paths
|
|
742
|
-
+ "\
|
|
778
|
+
+ "\nTool availability is controlled by runner and user policy; this instruction "
|
|
779
|
+
+ "does not impose a universal tool ban. Do not create symlinks.\n"
|
|
743
780
|
)
|
|
744
781
|
|
|
745
782
|
|
|
@@ -783,6 +820,7 @@ def run_apply_step(
|
|
|
783
820
|
if input_path.name.endswith("issues.json"):
|
|
784
821
|
issues = json.loads(input_path.read_text(encoding="utf-8"))
|
|
785
822
|
if isinstance(issues, list) and not issues:
|
|
823
|
+
# Legacy compatibility shortcut; keep until an explicit replacement exists.
|
|
786
824
|
return {"changed": False, "changed_files": [], "skipped": True}
|
|
787
825
|
|
|
788
826
|
_assert_apply_targets_checked_out_head(context)
|
|
@@ -798,17 +836,25 @@ def run_apply_step(
|
|
|
798
836
|
propagated_expected: dict[str, StagedFile | None] = {}
|
|
799
837
|
with tempfile.TemporaryDirectory(prefix="ai-push-hooks-apply-") as temporary_directory:
|
|
800
838
|
staging_root = pathlib.Path(temporary_directory).resolve(strict=True)
|
|
801
|
-
|
|
839
|
+
profile = resolve_runner_profile(context.config, step)
|
|
840
|
+
destination_baselines = _copy_checkout_to_staging(
|
|
841
|
+
context,
|
|
842
|
+
staging_root,
|
|
843
|
+
step.allow_paths,
|
|
844
|
+
project_access=profile.project_access,
|
|
845
|
+
)
|
|
802
846
|
staged_before = _inventory_staging(staging_root)
|
|
803
847
|
try:
|
|
804
|
-
result =
|
|
848
|
+
result = run_runner_once(
|
|
805
849
|
context,
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
850
|
+
step,
|
|
851
|
+
_apply_prompt(
|
|
852
|
+
prompt,
|
|
853
|
+
step.allow_paths,
|
|
854
|
+
project_access=profile.project_access,
|
|
855
|
+
),
|
|
856
|
+
validated_inputs,
|
|
857
|
+
stage_name,
|
|
812
858
|
working_directory=staging_root,
|
|
813
859
|
)
|
|
814
860
|
except Exception as exc: # noqa: BLE001
|
|
@@ -823,13 +869,6 @@ def run_apply_step(
|
|
|
823
869
|
if call_error is None:
|
|
824
870
|
call_error = exc
|
|
825
871
|
|
|
826
|
-
if result is not None:
|
|
827
|
-
try:
|
|
828
|
-
finalize_opencode_session(context, stage_name, result.session_id)
|
|
829
|
-
except Exception as exc: # noqa: BLE001
|
|
830
|
-
if call_error is None:
|
|
831
|
-
call_error = exc
|
|
832
|
-
|
|
833
872
|
_verify_pre_propagation_security_state(
|
|
834
873
|
context,
|
|
835
874
|
baseline,
|
|
@@ -840,9 +879,9 @@ def run_apply_step(
|
|
|
840
879
|
if call_error is not None:
|
|
841
880
|
raise HookError(f"Apply step failed in isolated staging: {call_error}") from call_error
|
|
842
881
|
if result is None:
|
|
843
|
-
raise HookError("Apply step failed without
|
|
844
|
-
if result.
|
|
845
|
-
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.
|
|
882
|
+
raise HookError("Apply step failed without a runner result")
|
|
883
|
+
if result.returncode != 0:
|
|
884
|
+
details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
|
|
846
885
|
raise HookError(f"Apply step failed in isolated staging: {details}")
|
|
847
886
|
propagated_expected = _propagate_staging_changes(
|
|
848
887
|
context,
|