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.
@@ -0,0 +1,473 @@
1
+ """Execution of configuration-defined exec and assert step commands.
2
+
3
+ This module is deliberately independent from the workflow engine. It resolves
4
+ the small command contract, delegates process lifecycle and bounded capture to
5
+ the common runner process utility, and exposes a persistence helper for the
6
+ engine integration lane.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import os
13
+ import pathlib
14
+ import re
15
+ import sys
16
+ from collections.abc import Mapping, Sequence
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from ..artifacts import ArtifactStore
21
+ from ..paths import path_is_link_or_reparse
22
+ from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
23
+ from .runners.contracts import bounded_diagnostic, redact_diagnostic
24
+ from .runners.process import (
25
+ DEFAULT_MAX_OUTPUT_BYTES,
26
+ ProcessResult,
27
+ run_process,
28
+ )
29
+
30
+ DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
31
+ STEP_COMMAND_STDOUT_ARTIFACT = "stdout.txt"
32
+ STEP_COMMAND_STDERR_ARTIFACT = "stderr.txt"
33
+ STEP_COMMAND_RESULT_ARTIFACT = "result.json"
34
+ _WHOLE_TOKEN = re.compile(r"\{([A-Za-z0-9_./:-]+)\}\Z")
35
+ _RECOGNIZED_TOKEN = re.compile(r"\{(?:repo|python|input:[A-Za-z0-9_./:-]+)\}")
36
+
37
+
38
+ class StepCommandError(HookError):
39
+ """Base class for safe step-command validation and normalization errors."""
40
+
41
+
42
+ class StepCommandOutputError(StepCommandError):
43
+ """The command exceeded its bounded output or emitted malformed text."""
44
+
45
+
46
+ class StepCommandEncodingError(StepCommandOutputError):
47
+ """A captured command stream was not valid UTF-8."""
48
+
49
+
50
+ class StepCommandTruncatedError(StepCommandOutputError):
51
+ """A captured command stream reached the per-stream bound."""
52
+
53
+
54
+ class StepCommandExecutionError(StepCommandError):
55
+ """An exec command returned a non-zero status."""
56
+
57
+
58
+ class StepCommandAssertionError(StepCommandError):
59
+ """An assert command returned a non-zero status after its report was saved."""
60
+
61
+
62
+ @dataclass(frozen=True, repr=False)
63
+ class StepCommandResult:
64
+ """The exact bounded bytes and process status from one command invocation."""
65
+
66
+ returncode: int
67
+ stdout: bytes = field(repr=False)
68
+ stderr: bytes = field(repr=False)
69
+ stdout_truncated: bool = False
70
+ stderr_truncated: bool = False
71
+
72
+ def __post_init__(self) -> None:
73
+ if isinstance(self.returncode, bool) or not isinstance(self.returncode, int):
74
+ raise TypeError("step command returncode must be an integer")
75
+ if not isinstance(self.stdout, bytes) or not isinstance(self.stderr, bytes):
76
+ raise TypeError("step command streams must be bytes")
77
+ if type(self.stdout_truncated) is not bool or type(self.stderr_truncated) is not bool:
78
+ raise TypeError("step command truncation flags must be booleans")
79
+
80
+ def __repr__(self) -> str:
81
+ return (
82
+ "StepCommandResult("
83
+ f"returncode={self.returncode!r}, stdout=<redacted>, stderr=<redacted>, "
84
+ f"stdout_truncated={self.stdout_truncated!r}, "
85
+ f"stderr_truncated={self.stderr_truncated!r})"
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class PersistedStepCommandResult:
91
+ """Result payload and private artifact paths prepared for engine dispatch."""
92
+
93
+ result: dict[str, Any]
94
+ artifacts: Mapping[str, pathlib.Path]
95
+
96
+
97
+ def _validate_command(command: Sequence[str]) -> tuple[str, ...]:
98
+ if not isinstance(command, (tuple, list)) or not command:
99
+ raise StepCommandError("step command must be a non-empty argv vector")
100
+ normalized: list[str] = []
101
+ for index, argument in enumerate(command, start=1):
102
+ if not isinstance(argument, str) or not argument or "\x00" in argument:
103
+ raise StepCommandError(
104
+ f"step command argument {index} must be a non-empty NUL-free string"
105
+ )
106
+ normalized.append(argument)
107
+ return tuple(normalized)
108
+
109
+
110
+ def _validated_inputs(
111
+ inputs: Mapping[str, pathlib.Path] | None,
112
+ ) -> dict[str, pathlib.Path]:
113
+ if inputs is None:
114
+ return {}
115
+ if not isinstance(inputs, Mapping):
116
+ raise StepCommandError("step command inputs must be a logical-reference mapping")
117
+ normalized: dict[str, pathlib.Path] = {}
118
+ for logical_ref, path in inputs.items():
119
+ if not isinstance(logical_ref, str) or not logical_ref:
120
+ raise StepCommandError("step command input references must be non-empty strings")
121
+ if not isinstance(path, pathlib.Path):
122
+ path = pathlib.Path(path)
123
+ try:
124
+ resolved = path.resolve(strict=True)
125
+ metadata = resolved.stat()
126
+ except (OSError, RuntimeError) as exc:
127
+ raise StepCommandError("step command input artifact could not be opened") from exc
128
+ if path_is_link_or_reparse(path) or not resolved.is_file() or not metadata:
129
+ raise StepCommandError("step command input artifact must be a regular file")
130
+ normalized[logical_ref] = resolved
131
+ return normalized
132
+
133
+
134
+ def _substitute_argv(
135
+ command: Sequence[str],
136
+ *,
137
+ repo_root: pathlib.Path,
138
+ inputs: Mapping[str, pathlib.Path],
139
+ python_executable: str,
140
+ ) -> tuple[str, ...]:
141
+ rendered: list[str] = []
142
+ for index, argument in enumerate(_validate_command(command), start=1):
143
+ match = _WHOLE_TOKEN.fullmatch(argument)
144
+ if match is not None:
145
+ token = match.group(1)
146
+ if token == "repo":
147
+ rendered.append(str(repo_root))
148
+ elif token == "python":
149
+ rendered.append(python_executable)
150
+ elif token.startswith("input:"):
151
+ logical_ref = token.removeprefix("input:")
152
+ if logical_ref not in inputs:
153
+ raise StepCommandError(
154
+ f"step command argument {index} references an undeclared input"
155
+ )
156
+ rendered.append(str(inputs[logical_ref]))
157
+ else:
158
+ raise StepCommandError(f"unknown step command placeholder in argument {index}")
159
+ continue
160
+
161
+ if _RECOGNIZED_TOKEN.search(argument):
162
+ raise StepCommandError(
163
+ f"recognized step command placeholders must be whole argv elements (argument {index})"
164
+ )
165
+ rendered.append(argument)
166
+ return tuple(rendered)
167
+
168
+
169
+ def resolve_step_command_argv(
170
+ command: Sequence[str],
171
+ repo_root: pathlib.Path,
172
+ inputs: Mapping[str, pathlib.Path] | None = None,
173
+ *,
174
+ python_executable: str | None = None,
175
+ ) -> tuple[str, ...]:
176
+ """Resolve reserved placeholders without parsing or invoking a shell."""
177
+
178
+ try:
179
+ root = pathlib.Path(repo_root).resolve(strict=True)
180
+ except (OSError, RuntimeError) as exc:
181
+ raise StepCommandError("step command repository root could not be resolved") from exc
182
+ if not root.is_dir():
183
+ raise StepCommandError("step command repository root must be a directory")
184
+ executable = python_executable or sys.executable
185
+ if not isinstance(executable, str) or not executable or "\x00" in executable:
186
+ raise StepCommandError("step command Python interpreter must be a non-empty NUL-free string")
187
+ return _substitute_argv(
188
+ command,
189
+ repo_root=root,
190
+ inputs=_validated_inputs(inputs),
191
+ python_executable=executable,
192
+ )
193
+
194
+
195
+ def _from_process_result(result: ProcessResult) -> StepCommandResult:
196
+ stdout_bytes = result.stdout_bytes
197
+ stderr_bytes = result.stderr_bytes
198
+ # Test doubles and older in-process callers may construct ProcessResult
199
+ # without the new byte fields. Surrogateescape preserves their text bytes.
200
+ if not stdout_bytes and result.stdout:
201
+ stdout_bytes = result.stdout.encode("utf-8", errors="surrogateescape")
202
+ if not stderr_bytes and result.stderr:
203
+ stderr_bytes = result.stderr.encode("utf-8", errors="surrogateescape")
204
+ return StepCommandResult(
205
+ returncode=result.returncode,
206
+ stdout=stdout_bytes,
207
+ stderr=stderr_bytes,
208
+ stdout_truncated=result.stdout_truncated,
209
+ stderr_truncated=result.stderr_truncated,
210
+ )
211
+
212
+
213
+ def _result_from_error(error: BaseException) -> StepCommandResult | None:
214
+ process_result = getattr(error, "_process_result", None)
215
+ if not isinstance(process_result, ProcessResult):
216
+ return None
217
+ return _from_process_result(process_result)
218
+
219
+
220
+ def _validate_utf8(result: StepCommandResult) -> None:
221
+ for name, stream in (("stdout", result.stdout), ("stderr", result.stderr)):
222
+ try:
223
+ stream.decode("utf-8")
224
+ except UnicodeDecodeError as exc:
225
+ error = StepCommandEncodingError(f"step command emitted invalid UTF-8 on {name}")
226
+ error._step_command_result = result
227
+ raise error from exc
228
+
229
+
230
+ def run_step_command(
231
+ command: Sequence[str],
232
+ repo_root: pathlib.Path,
233
+ *,
234
+ inputs: Mapping[str, pathlib.Path] | None = None,
235
+ stdin: str | None = None,
236
+ timeout_seconds: float = DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
237
+ python_executable: str | None = None,
238
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
239
+ ) -> StepCommandResult:
240
+ """Run an exec/assert command and return bounded exact output bytes.
241
+
242
+ The child receives the invoking environment, runs at the canonical
243
+ repository root, and receives EOF unless ``stdin`` names an input artifact.
244
+ Process timeout/signal/missing-executable errors retain captured output on
245
+ their private ``_process_result`` attribute for the persistence helper.
246
+ """
247
+
248
+ if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)):
249
+ raise StepCommandError("step command timeout must be a finite positive number")
250
+ if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
251
+ raise StepCommandError("step command timeout must be a finite positive number")
252
+ if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes < 0:
253
+ raise StepCommandError("step command output bound must be a non-negative integer")
254
+
255
+ input_paths = _validated_inputs(inputs)
256
+ if stdin is not None:
257
+ if not isinstance(stdin, str) or stdin not in input_paths:
258
+ raise StepCommandError("step command stdin must exactly match a declared input")
259
+ input_path = input_paths[stdin]
260
+ else:
261
+ input_path = None
262
+ argv = _substitute_argv(
263
+ command,
264
+ repo_root=pathlib.Path(repo_root).resolve(strict=True),
265
+ inputs=input_paths,
266
+ python_executable=python_executable or sys.executable,
267
+ )
268
+ process_result = run_process(
269
+ argv,
270
+ cwd=pathlib.Path(repo_root).resolve(strict=True),
271
+ input_path=input_path,
272
+ timeout_seconds=timeout_seconds,
273
+ env=None,
274
+ max_output_bytes=max_output_bytes,
275
+ )
276
+ result = _from_process_result(process_result)
277
+ if result.stdout_truncated or result.stderr_truncated:
278
+ streams = " and ".join(
279
+ name
280
+ for name, truncated in (
281
+ ("stdout", result.stdout_truncated),
282
+ ("stderr", result.stderr_truncated),
283
+ )
284
+ if truncated
285
+ )
286
+ error = StepCommandTruncatedError(f"step command {streams} exceeded its capture limit")
287
+ error._step_command_result = result
288
+ raise error
289
+ _validate_utf8(result)
290
+ return result
291
+
292
+
293
+ def _environment_secrets() -> tuple[str, ...]:
294
+ markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
295
+ return tuple(
296
+ value
297
+ for name, value in os.environ.items()
298
+ if any(marker in name.upper() for marker in markers) and value
299
+ )
300
+
301
+
302
+ def _assert_message(result: StepCommandResult) -> str:
303
+ # A process error can occur before the normal strict UTF-8 validation pass.
304
+ # Surrogateescape keeps that diagnostic bounded and non-throwing; the
305
+ # private stream artifacts still retain the exact original bytes.
306
+ stdout = result.stdout.decode("utf-8", errors="surrogateescape")
307
+ stderr = result.stderr.decode("utf-8", errors="surrogateescape")
308
+ combined = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
309
+ safe = redact_diagnostic(combined, secrets=_environment_secrets())
310
+ return bounded_diagnostic(safe, max_chars=1_200) or f"command exited with status {result.returncode}"
311
+
312
+
313
+ def step_command_result_payload(
314
+ result: StepCommandResult,
315
+ *,
316
+ step_type: str,
317
+ stdout_artifact: str = STEP_COMMAND_STDOUT_ARTIFACT,
318
+ stderr_artifact: str = STEP_COMMAND_STDERR_ARTIFACT,
319
+ ) -> dict[str, Any]:
320
+ """Normalize a successful or process-started command into result.json data."""
321
+
322
+ if step_type not in {"exec", "assert"}:
323
+ raise StepCommandError("step command implementation requires exec or assert")
324
+ payload: dict[str, Any] = {
325
+ "returncode": result.returncode,
326
+ "stdout_artifact": stdout_artifact,
327
+ "stderr_artifact": stderr_artifact,
328
+ "stdout_truncated": result.stdout_truncated,
329
+ "stderr_truncated": result.stderr_truncated,
330
+ }
331
+ if step_type == "assert":
332
+ payload["ok"] = result.returncode == 0
333
+ if result.returncode != 0:
334
+ payload["message"] = _assert_message(result)
335
+ return payload
336
+
337
+
338
+ def _persist_process_result(
339
+ store: ArtifactStore,
340
+ state: ModuleRuntimeState,
341
+ step: StepConfig,
342
+ result: StepCommandResult,
343
+ *,
344
+ payload: dict[str, Any] | None = None,
345
+ ) -> PersistedStepCommandResult:
346
+ stdout_path = store.write_bytes(
347
+ state,
348
+ state.step_index,
349
+ step.id,
350
+ STEP_COMMAND_STDOUT_ARTIFACT,
351
+ result.stdout,
352
+ )
353
+ stderr_path = store.write_bytes(
354
+ state,
355
+ state.step_index,
356
+ step.id,
357
+ STEP_COMMAND_STDERR_ARTIFACT,
358
+ result.stderr,
359
+ )
360
+ report = payload or step_command_result_payload(result, step_type=step.type)
361
+ report_path = store.write_json(
362
+ state,
363
+ state.step_index,
364
+ step.id,
365
+ STEP_COMMAND_RESULT_ARTIFACT,
366
+ report,
367
+ )
368
+ return PersistedStepCommandResult(
369
+ result=report,
370
+ artifacts={
371
+ STEP_COMMAND_STDOUT_ARTIFACT: stdout_path,
372
+ STEP_COMMAND_STDERR_ARTIFACT: stderr_path,
373
+ STEP_COMMAND_RESULT_ARTIFACT: report_path,
374
+ },
375
+ )
376
+
377
+
378
+ def execute_step_command(
379
+ context: RuntimeContext,
380
+ state: ModuleRuntimeState,
381
+ step: StepConfig,
382
+ inputs: Mapping[str, pathlib.Path] | Sequence[pathlib.Path],
383
+ *,
384
+ artifacts: ArtifactStore | None = None,
385
+ ) -> PersistedStepCommandResult:
386
+ """Run and persist one configured command for future engine dispatch.
387
+
388
+ The report and both private stream artifacts are written before an exec
389
+ non-zero or assert false result raises. Process errors retain their
390
+ bounded streams and are persisted when a process started; a missing
391
+ executable has no process output and therefore creates no artifacts.
392
+ """
393
+
394
+ if step.type not in {"exec", "assert"}:
395
+ raise StepCommandError("step command implementation requires an exec or assert step")
396
+ if isinstance(inputs, Mapping):
397
+ input_map = dict(inputs)
398
+ declared_references = set(step.inputs)
399
+ if set(input_map) != declared_references:
400
+ raise StepCommandError(
401
+ "step command input mapping must contain exactly the declared references"
402
+ )
403
+ else:
404
+ if len(inputs) != len(step.inputs):
405
+ raise StepCommandError("step command input paths do not match declared inputs")
406
+ input_map = dict(zip(step.inputs, inputs))
407
+ store = artifacts or ArtifactStore(context.run_dir)
408
+ store.prepare()
409
+ try:
410
+ process_result = run_step_command(
411
+ step.command,
412
+ context.repo_root,
413
+ inputs=input_map,
414
+ stdin=step.stdin,
415
+ timeout_seconds=(
416
+ step.timeout_seconds
417
+ if step.timeout_seconds is not None
418
+ else DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS
419
+ ),
420
+ )
421
+ except BaseException as error:
422
+ captured = _result_from_error(error)
423
+ if captured is None:
424
+ captured = getattr(error, "_step_command_result", None)
425
+ if isinstance(captured, StepCommandResult):
426
+ report = None
427
+ if isinstance(error, StepCommandEncodingError):
428
+ report = step_command_result_payload(captured, step_type=step.type)
429
+ report["malformed"] = True
430
+ report["message"] = "command output was not valid UTF-8"
431
+ persisted = _persist_process_result(
432
+ store,
433
+ state,
434
+ step,
435
+ captured,
436
+ payload=report,
437
+ )
438
+ error._step_command_persisted = persisted
439
+ raise
440
+
441
+ payload = step_command_result_payload(process_result, step_type=step.type)
442
+ persisted = _persist_process_result(store, state, step, process_result, payload=payload)
443
+ if step.type == "exec" and process_result.returncode != 0:
444
+ error = StepCommandExecutionError("step exec command returned a non-zero status")
445
+ error._step_command_result = process_result
446
+ error._step_command_persisted = persisted
447
+ raise error
448
+ if step.type == "assert" and process_result.returncode != 0:
449
+ error = StepCommandAssertionError(str(payload["message"]))
450
+ error._step_command_result = process_result
451
+ error._step_command_persisted = persisted
452
+ raise error
453
+ return persisted
454
+
455
+
456
+ __all__ = [
457
+ "DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS",
458
+ "STEP_COMMAND_RESULT_ARTIFACT",
459
+ "STEP_COMMAND_STDERR_ARTIFACT",
460
+ "STEP_COMMAND_STDOUT_ARTIFACT",
461
+ "PersistedStepCommandResult",
462
+ "StepCommandAssertionError",
463
+ "StepCommandEncodingError",
464
+ "StepCommandError",
465
+ "StepCommandExecutionError",
466
+ "StepCommandOutputError",
467
+ "StepCommandResult",
468
+ "StepCommandTruncatedError",
469
+ "execute_step_command",
470
+ "resolve_step_command_argv",
471
+ "run_step_command",
472
+ "step_command_result_payload",
473
+ ]