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.
@@ -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.llm import run_llm_step
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 .types import CollectorResult, HookError, ModuleRuntimeState, RuntimeContext, StepConfig, StepResult, WorkflowRunResult
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
- llm_executor: Callable[[RuntimeContext, StepConfig, str, list[pathlib.Path], str], Any] = run_llm_step,
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.llm_executor = llm_executor
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 == "llm":
144
+ if step.type == "ask":
119
145
  prompt = resolve_prompt_text(self.context.repo_root, step)
120
- payload = self.llm_executor(self.context, step, prompt, input_paths, stage_name)
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 .llm import call_opencode, finalize_opencode_session, validate_opencode_attachments
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
- allowed_paths = {
368
- relative_path
369
- for relative_path in _tracked_and_unignored_paths(repo_root)
370
- if not _is_protected_path(relative_path)
371
- and any(path_matches(relative_path, pattern) for pattern in allow_paths)
372
- }
373
- allowed_paths -= _ignored_changed_paths(repo_root, allowed_paths)
374
- for relative_path in sorted(allowed_paths):
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.is_file():
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 _is_protected_path(path)
453
- or not any(path_matches(path, pattern) for pattern in allow_paths)
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(prompt: str, allow_paths: tuple[str, ...]) -> str:
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 WRITE BOUNDARY:\n"
740
- + "This workspace contains only approved files. Modify only paths matching:\n"
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
- + "\nDo not create symlinks. Do not use commands, tasks, web access, or external paths.\n"
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
- destination_baselines = _copy_checkout_to_staging(context, staging_root, step.allow_paths)
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 = call_opencode(
848
+ result = run_runner_once(
805
849
  context,
806
- stage_name=stage_name,
807
- purpose=f"{step.type}:{step.id}",
808
- prompt=_apply_prompt(prompt, step.allow_paths),
809
- files=validated_inputs,
810
- agent="apply",
811
- allow_paths=step.allow_paths,
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 an OpenCode result")
844
- if result.return_code != 0:
845
- details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.return_code}"
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,