ai-push-hooks 0.3.1 → 0.3.3
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 +69 -1
- package/README.md +90 -82
- package/SECURITY.md +7 -2
- package/ai-push-hooks.toml +1 -1
- package/docs/configuration.md +339 -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,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import errno
|
|
3
4
|
import math
|
|
4
5
|
import os
|
|
5
6
|
import pathlib
|
|
@@ -108,6 +109,30 @@ EMBEDDED_COMMAND_PLACEHOLDER_PATTERN = re.compile(
|
|
|
108
109
|
)
|
|
109
110
|
COMMAND_PLACEHOLDER_NAMES = frozenset({"repo", "python"})
|
|
110
111
|
RUNNER_PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
|
|
112
|
+
CONFIG_MAX_BYTES = 1 * 1024 * 1024
|
|
113
|
+
PROMPT_MAX_BYTES = 256 * 1024
|
|
114
|
+
_FILE_READ_CHUNK_BYTES = 64 * 1024
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_descriptor_limited(
|
|
118
|
+
descriptor: int, max_bytes: int, oversize_message: str
|
|
119
|
+
) -> bytes:
|
|
120
|
+
"""Read an already-validated descriptor without exceeding ``max_bytes``."""
|
|
121
|
+
|
|
122
|
+
if os.fstat(descriptor).st_size > max_bytes:
|
|
123
|
+
raise HookError(oversize_message)
|
|
124
|
+
content = bytearray()
|
|
125
|
+
while True:
|
|
126
|
+
read_limit = min(
|
|
127
|
+
_FILE_READ_CHUNK_BYTES,
|
|
128
|
+
max_bytes - len(content) + 1,
|
|
129
|
+
)
|
|
130
|
+
chunk = os.read(descriptor, max(1, read_limit))
|
|
131
|
+
if not chunk:
|
|
132
|
+
return bytes(content)
|
|
133
|
+
content.extend(chunk)
|
|
134
|
+
if len(content) > max_bytes:
|
|
135
|
+
raise HookError(oversize_message)
|
|
111
136
|
|
|
112
137
|
|
|
113
138
|
def _require_table(value: Any, label: str) -> dict[str, Any]:
|
|
@@ -116,7 +141,9 @@ def _require_table(value: Any, label: str) -> dict[str, Any]:
|
|
|
116
141
|
return value
|
|
117
142
|
|
|
118
143
|
|
|
119
|
-
def _validate_unknown_keys(
|
|
144
|
+
def _validate_unknown_keys(
|
|
145
|
+
table: dict[str, Any], allowed: set[str], label: str
|
|
146
|
+
) -> None:
|
|
120
147
|
unknown = set(table) - allowed
|
|
121
148
|
if unknown:
|
|
122
149
|
raise HookError(f"Unknown field(s) in {label}: {', '.join(sorted(unknown))}")
|
|
@@ -182,7 +209,9 @@ def _validate_runner_command_placeholders(
|
|
|
182
209
|
_validate_no_control_chars(argument, argument_label)
|
|
183
210
|
for placeholder in RUNNER_PLACEHOLDER_PATTERN.findall(argument):
|
|
184
211
|
if placeholder not in RUNNER_PLACEHOLDERS:
|
|
185
|
-
raise HookError(
|
|
212
|
+
raise HookError(
|
|
213
|
+
f"Unknown placeholder {placeholder!r} in {argument_label}"
|
|
214
|
+
)
|
|
186
215
|
if ("{" in argument or "}" in argument) and argument not in RUNNER_PLACEHOLDERS:
|
|
187
216
|
raise HookError(
|
|
188
217
|
f"Placeholders in {argument_label} must be whole argv elements"
|
|
@@ -190,13 +219,17 @@ def _validate_runner_command_placeholders(
|
|
|
190
219
|
if argument == "{prompt}":
|
|
191
220
|
prompt_count += 1
|
|
192
221
|
if transport == "stdin" and prompt_count:
|
|
193
|
-
raise HookError(
|
|
222
|
+
raise HookError(
|
|
223
|
+
f"{label}.command must not contain {{prompt}} with stdin transport"
|
|
224
|
+
)
|
|
194
225
|
if transport == "argv" and prompt_count != 1:
|
|
195
226
|
raise HookError(
|
|
196
227
|
f"{label}.command must contain exactly one {{prompt}} with argv transport"
|
|
197
228
|
)
|
|
198
229
|
if "{model}" in command and not (model or effective_model):
|
|
199
|
-
raise HookError(
|
|
230
|
+
raise HookError(
|
|
231
|
+
f"{label}.command uses {{model}} but {label}.model is not configured"
|
|
232
|
+
)
|
|
200
233
|
|
|
201
234
|
|
|
202
235
|
def _validate_runner_profiles(
|
|
@@ -216,7 +249,9 @@ def _validate_runner_profiles(
|
|
|
216
249
|
runner_type = profile["type"].strip()
|
|
217
250
|
_validate_no_control_chars(profile["type"], f"{label}.type")
|
|
218
251
|
if runner_type not in RUNNER_TYPES:
|
|
219
|
-
raise HookError(
|
|
252
|
+
raise HookError(
|
|
253
|
+
f"{label}.type must be one of: {', '.join(sorted(RUNNER_TYPES))}"
|
|
254
|
+
)
|
|
220
255
|
_validate_string(profile, "model", label)
|
|
221
256
|
if "model" in profile and not profile["model"].strip():
|
|
222
257
|
raise HookError(f"{label}.model must be a non-empty string when provided")
|
|
@@ -227,8 +262,13 @@ def _validate_runner_profiles(
|
|
|
227
262
|
_validate_no_control_chars(profile["variant"], f"{label}.variant")
|
|
228
263
|
_validate_string(profile, "project_access", label)
|
|
229
264
|
_validate_string(profile, "prompt_transport", label)
|
|
230
|
-
if
|
|
231
|
-
|
|
265
|
+
if (
|
|
266
|
+
"project_access" in profile
|
|
267
|
+
and profile["project_access"] not in PROJECT_ACCESS_VALUES
|
|
268
|
+
):
|
|
269
|
+
raise HookError(
|
|
270
|
+
f"{label}.project_access must be one of: artifacts, project"
|
|
271
|
+
)
|
|
232
272
|
|
|
233
273
|
type_specific_keys = {
|
|
234
274
|
"variant": runner_type == "opencode",
|
|
@@ -237,7 +277,9 @@ def _validate_runner_profiles(
|
|
|
237
277
|
}
|
|
238
278
|
for key, applicable in type_specific_keys.items():
|
|
239
279
|
if key in profile and not applicable:
|
|
240
|
-
raise HookError(
|
|
280
|
+
raise HookError(
|
|
281
|
+
f"{label}.{key} is only valid for runner type {('opencode' if key == 'variant' else 'command')}"
|
|
282
|
+
)
|
|
241
283
|
|
|
242
284
|
if runner_type != "command":
|
|
243
285
|
continue
|
|
@@ -296,9 +338,7 @@ def _validate_json_options(value: Any, label: str, *, path: str = "") -> None:
|
|
|
296
338
|
raise HookError(f"{location} must use string keys")
|
|
297
339
|
_validate_json_options(item, label, path=f"{path}.{key}")
|
|
298
340
|
return
|
|
299
|
-
raise HookError(
|
|
300
|
-
f"{location} must contain only JSON-compatible null-free values"
|
|
301
|
-
)
|
|
341
|
+
raise HookError(f"{location} must contain only JSON-compatible null-free values")
|
|
302
342
|
|
|
303
343
|
|
|
304
344
|
def _validate_python_reference(
|
|
@@ -331,15 +371,15 @@ def _validate_python_reference(
|
|
|
331
371
|
if path_has_symlink(root, lexical_path):
|
|
332
372
|
raise HookError(f"{label} path must not traverse a symlink or reparse point")
|
|
333
373
|
try:
|
|
334
|
-
callback_path = resolve_contained_path(
|
|
335
|
-
root, "/".join(parts), f"{label} path"
|
|
336
|
-
)
|
|
374
|
+
callback_path = resolve_contained_path(root, "/".join(parts), f"{label} path")
|
|
337
375
|
except HookError as exc:
|
|
338
376
|
raise HookError(str(exc)) from exc
|
|
339
377
|
try:
|
|
340
378
|
metadata = callback_path.lstat()
|
|
341
379
|
except FileNotFoundError as exc:
|
|
342
|
-
raise HookError(
|
|
380
|
+
raise HookError(
|
|
381
|
+
f"{label} path must reference an existing regular file"
|
|
382
|
+
) from exc
|
|
343
383
|
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
344
384
|
if stat.S_ISLNK(metadata.st_mode) or bool(
|
|
345
385
|
getattr(metadata, "st_file_attributes", 0) & reparse_flag
|
|
@@ -470,7 +510,8 @@ def _validate_config_types(raw: dict[str, Any]) -> None:
|
|
|
470
510
|
unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
|
|
471
511
|
if unknown:
|
|
472
512
|
raise HookError(
|
|
473
|
-
"Legacy or unsupported config keys are not allowed: "
|
|
513
|
+
"Legacy or unsupported config keys are not allowed: "
|
|
514
|
+
+ ", ".join(sorted(unknown))
|
|
474
515
|
)
|
|
475
516
|
|
|
476
517
|
general = _require_table(raw.get("general", {}), "general")
|
|
@@ -512,6 +553,15 @@ def _validate_config_types(raw: dict[str, Any]) -> None:
|
|
|
512
553
|
workflow = _require_table(raw.get("workflow", {}), "workflow")
|
|
513
554
|
_validate_unknown_keys(workflow, {"modules"}, "workflow")
|
|
514
555
|
_validate_string_list(workflow, "modules", "workflow")
|
|
556
|
+
if "modules" in workflow:
|
|
557
|
+
module_ids: set[str] = set()
|
|
558
|
+
for module_id in workflow["modules"]:
|
|
559
|
+
normalized_module_id = module_id.strip()
|
|
560
|
+
if normalized_module_id in module_ids:
|
|
561
|
+
raise HookError(
|
|
562
|
+
f"Duplicate module id `{normalized_module_id}` in workflow.modules"
|
|
563
|
+
)
|
|
564
|
+
module_ids.add(normalized_module_id)
|
|
515
565
|
|
|
516
566
|
modules = _require_table(raw.get("modules", {}), "modules")
|
|
517
567
|
for module_id, module_value in modules.items():
|
|
@@ -524,12 +574,21 @@ def _validate_config_types(raw: dict[str, Any]) -> None:
|
|
|
524
574
|
steps = module["steps"]
|
|
525
575
|
if not isinstance(steps, (list, tuple)):
|
|
526
576
|
raise HookError(f"modules.{module_id}.steps must be an array of tables")
|
|
577
|
+
step_ids: set[str] = set()
|
|
527
578
|
for index, step_value in enumerate(steps, start=1):
|
|
528
579
|
step = _require_table(step_value, f"modules.{module_id}.steps[{index}]")
|
|
529
580
|
label = f"modules.{module_id}.steps[{index}]"
|
|
530
581
|
_validate_unknown_keys(step, STEP_KEYS, label)
|
|
531
582
|
for key in ("id", "type"):
|
|
532
583
|
_validate_string(step, key, label)
|
|
584
|
+
step_id = step.get("id")
|
|
585
|
+
if isinstance(step_id, str):
|
|
586
|
+
normalized_step_id = step_id.strip()
|
|
587
|
+
if normalized_step_id in step_ids:
|
|
588
|
+
raise HookError(
|
|
589
|
+
f"Duplicate step id `{normalized_step_id}` at {label}.id"
|
|
590
|
+
)
|
|
591
|
+
step_ids.add(normalized_step_id)
|
|
533
592
|
if isinstance(step.get("type"), str):
|
|
534
593
|
_reject_legacy_step_type(step["type"].strip(), label)
|
|
535
594
|
for key in (
|
|
@@ -552,7 +611,11 @@ def _validate_config_types(raw: dict[str, Any]) -> None:
|
|
|
552
611
|
raise HookError(f"{label}.options must be a table")
|
|
553
612
|
_validate_string(step, "stdin", label, allow_none=True)
|
|
554
613
|
_validate_integer(step, "timeout_seconds", label, minimum=1)
|
|
555
|
-
if
|
|
614
|
+
if (
|
|
615
|
+
"runner" in step
|
|
616
|
+
and step["runner"] is not None
|
|
617
|
+
and not step["runner"].strip()
|
|
618
|
+
):
|
|
556
619
|
raise HookError(f"{label}.runner must be a non-empty string")
|
|
557
620
|
if "runner" in step and step["runner"] is not None:
|
|
558
621
|
_validate_no_control_chars(step["runner"], f"{label}.runner")
|
|
@@ -561,9 +624,12 @@ def _validate_config_types(raw: dict[str, Any]) -> None:
|
|
|
561
624
|
and isinstance(step.get("type"), str)
|
|
562
625
|
and step["type"] in {"collect", "exec", "assert"}
|
|
563
626
|
):
|
|
564
|
-
raise HookError(
|
|
627
|
+
raise HookError(
|
|
628
|
+
f"{label}.runner is only valid on ask and apply steps"
|
|
629
|
+
)
|
|
565
630
|
_validate_step_extensions(step, label)
|
|
566
631
|
|
|
632
|
+
|
|
567
633
|
def _normalize_runner_profile(name: str, raw: dict[str, Any]) -> RunnerProfile:
|
|
568
634
|
runner_type = str(raw["type"]).strip()
|
|
569
635
|
return RunnerProfile(
|
|
@@ -594,20 +660,36 @@ def _normalize_step(
|
|
|
594
660
|
id=str(raw.get("id", "")).strip(),
|
|
595
661
|
type=step_type,
|
|
596
662
|
inputs=tuple(str(item) for item in raw.get("inputs", []) or []),
|
|
597
|
-
output=str(raw.get("output")).strip()
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
663
|
+
output=str(raw.get("output")).strip()
|
|
664
|
+
if raw.get("output") is not None
|
|
665
|
+
else None,
|
|
666
|
+
schema=str(raw.get("schema")).strip()
|
|
667
|
+
if raw.get("schema") is not None
|
|
668
|
+
else None,
|
|
669
|
+
prompt=str(raw.get("prompt")).strip()
|
|
670
|
+
if raw.get("prompt") is not None
|
|
671
|
+
else None,
|
|
672
|
+
prompt_file=str(raw.get("prompt_file")).strip()
|
|
673
|
+
if raw.get("prompt_file") is not None
|
|
674
|
+
else None,
|
|
601
675
|
fallback_prompt_id=(
|
|
602
676
|
str(raw.get("fallback_prompt_id")).strip()
|
|
603
677
|
if raw.get("fallback_prompt_id") is not None
|
|
604
678
|
else None
|
|
605
679
|
),
|
|
606
|
-
collector=str(raw.get("collector")).strip()
|
|
680
|
+
collector=str(raw.get("collector")).strip()
|
|
681
|
+
if raw.get("collector") is not None
|
|
682
|
+
else None,
|
|
607
683
|
allow_paths=tuple(str(item) for item in raw.get("allow_paths", []) or []),
|
|
608
|
-
executor=str(raw.get("executor")).strip()
|
|
609
|
-
|
|
610
|
-
|
|
684
|
+
executor=str(raw.get("executor")).strip()
|
|
685
|
+
if raw.get("executor") is not None
|
|
686
|
+
else None,
|
|
687
|
+
assertion=str(raw.get("assertion")).strip()
|
|
688
|
+
if raw.get("assertion") is not None
|
|
689
|
+
else None,
|
|
690
|
+
python=str(raw.get("python")).strip()
|
|
691
|
+
if raw.get("python") is not None
|
|
692
|
+
else None,
|
|
611
693
|
options=dict(raw.get("options", {}) or {}),
|
|
612
694
|
command=tuple(str(item) for item in raw.get("command", []) or []),
|
|
613
695
|
stdin=str(raw.get("stdin")).strip() if raw.get("stdin") is not None else None,
|
|
@@ -616,8 +698,12 @@ def _normalize_step(
|
|
|
616
698
|
if raw.get("timeout_seconds") is not None
|
|
617
699
|
else (DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS if raw.get("command") else None)
|
|
618
700
|
),
|
|
619
|
-
when_env=str(raw.get("when_env")).strip()
|
|
620
|
-
|
|
701
|
+
when_env=str(raw.get("when_env")).strip()
|
|
702
|
+
if raw.get("when_env") is not None
|
|
703
|
+
else None,
|
|
704
|
+
runner=str(raw.get("runner")).strip()
|
|
705
|
+
if raw.get("runner") is not None
|
|
706
|
+
else None,
|
|
621
707
|
)
|
|
622
708
|
if not step.id:
|
|
623
709
|
raise HookError("Every workflow step requires a non-empty id")
|
|
@@ -630,8 +716,12 @@ def _normalize_step(
|
|
|
630
716
|
raise HookError(f"Apply step `{step.id}` may not allow Git metadata paths")
|
|
631
717
|
if normalized_component(parts[-1]) == "agents.md":
|
|
632
718
|
raise HookError(f"Apply step `{step.id}` may not allow AGENTS.md")
|
|
633
|
-
if step.is_promptable and not any(
|
|
634
|
-
|
|
719
|
+
if step.is_promptable and not any(
|
|
720
|
+
[step.prompt, step.prompt_file, step.fallback_prompt_id]
|
|
721
|
+
):
|
|
722
|
+
raise HookError(
|
|
723
|
+
f"Promptable step `{step.id}` requires prompt, prompt_file, or fallback_prompt_id"
|
|
724
|
+
)
|
|
635
725
|
if step.type == "collect" and not (step.collector or step.python):
|
|
636
726
|
raise HookError(f"Collect step `{step.id}` requires collector or python")
|
|
637
727
|
if step.type == "ask" and not step.output:
|
|
@@ -641,12 +731,16 @@ def _normalize_step(
|
|
|
641
731
|
if step.type == "exec" and not (step.executor or step.python or step.command):
|
|
642
732
|
raise HookError(f"Exec step `{step.id}` requires executor, python, or command")
|
|
643
733
|
if step.type == "assert" and not (step.assertion or step.python or step.command):
|
|
644
|
-
raise HookError(
|
|
734
|
+
raise HookError(
|
|
735
|
+
f"Assert step `{step.id}` requires assertion, python, or command"
|
|
736
|
+
)
|
|
645
737
|
return step
|
|
646
738
|
|
|
647
739
|
|
|
648
740
|
def _build_config(
|
|
649
|
-
raw: dict[str, Any],
|
|
741
|
+
raw: dict[str, Any],
|
|
742
|
+
*,
|
|
743
|
+
effective_model: str | None = None,
|
|
650
744
|
repo_root: pathlib.Path | None = None,
|
|
651
745
|
) -> HookConfig:
|
|
652
746
|
if not isinstance(raw, dict):
|
|
@@ -656,10 +750,13 @@ def _build_config(
|
|
|
656
750
|
unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
|
|
657
751
|
if unknown:
|
|
658
752
|
raise HookError(
|
|
659
|
-
"Legacy or unsupported config keys are not allowed: "
|
|
753
|
+
"Legacy or unsupported config keys are not allowed: "
|
|
754
|
+
+ ", ".join(sorted(unknown))
|
|
660
755
|
)
|
|
661
756
|
|
|
662
|
-
workflow_modules = tuple(
|
|
757
|
+
workflow_modules = tuple(
|
|
758
|
+
str(item) for item in raw.get("workflow", {}).get("modules", []) or []
|
|
759
|
+
)
|
|
663
760
|
if not workflow_modules:
|
|
664
761
|
raise HookError("workflow.modules must define at least one module id")
|
|
665
762
|
|
|
@@ -697,7 +794,9 @@ def _build_config(
|
|
|
697
794
|
if repo_root is not None:
|
|
698
795
|
validated_python_references: set[str] = set()
|
|
699
796
|
for module_id, module_raw in module_payload.items():
|
|
700
|
-
for index, step_raw in enumerate(
|
|
797
|
+
for index, step_raw in enumerate(
|
|
798
|
+
module_raw.get("steps", []) or [], start=1
|
|
799
|
+
):
|
|
701
800
|
python_ref = step_raw.get("python")
|
|
702
801
|
if python_ref is not None:
|
|
703
802
|
if python_ref in validated_python_references:
|
|
@@ -811,7 +910,7 @@ def _apply_env_overrides(
|
|
|
811
910
|
"llm": config.llm.__dict__.copy(),
|
|
812
911
|
"logging": config.logging.__dict__.copy(),
|
|
813
912
|
"workflow": {"modules": list(config.workflow.modules)},
|
|
814
|
-
|
|
913
|
+
"modules": {},
|
|
815
914
|
"runners": {},
|
|
816
915
|
}
|
|
817
916
|
for module_id, module in config.modules.items():
|
|
@@ -918,17 +1017,61 @@ def _apply_env_overrides(
|
|
|
918
1017
|
|
|
919
1018
|
def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
|
|
920
1019
|
config_path = repo_root / "ai-push-hooks.toml"
|
|
921
|
-
|
|
1020
|
+
try:
|
|
1021
|
+
path_metadata = config_path.lstat()
|
|
1022
|
+
except FileNotFoundError:
|
|
922
1023
|
raise HookError(
|
|
923
1024
|
"Missing required config file `ai-push-hooks.toml` in repo root. "
|
|
924
1025
|
"Run `ai-push-hooks init --template minimal-docs` first"
|
|
925
1026
|
)
|
|
1027
|
+
except OSError as exc:
|
|
1028
|
+
raise HookError(f"Could not inspect config file {config_path}: {exc}") from exc
|
|
1029
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
1030
|
+
if stat.S_ISLNK(path_metadata.st_mode) or bool(
|
|
1031
|
+
getattr(path_metadata, "st_file_attributes", 0) & reparse_flag
|
|
1032
|
+
):
|
|
1033
|
+
raise HookError(
|
|
1034
|
+
f"Config file must not be a symlink or reparse point: {config_path}"
|
|
1035
|
+
)
|
|
1036
|
+
if not stat.S_ISREG(path_metadata.st_mode):
|
|
1037
|
+
raise HookError(f"Config file must be a regular file: {config_path}")
|
|
1038
|
+
|
|
1039
|
+
descriptor = -1
|
|
926
1040
|
try:
|
|
927
|
-
|
|
1041
|
+
flags = (
|
|
1042
|
+
os.O_RDONLY
|
|
1043
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
1044
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
1045
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
1046
|
+
)
|
|
1047
|
+
descriptor = os.open(config_path, flags)
|
|
1048
|
+
descriptor_metadata = os.fstat(descriptor)
|
|
1049
|
+
if not stat.S_ISREG(descriptor_metadata.st_mode):
|
|
1050
|
+
raise HookError(f"Config file must be a regular file: {config_path}")
|
|
1051
|
+
content = _read_descriptor_limited(
|
|
1052
|
+
descriptor,
|
|
1053
|
+
CONFIG_MAX_BYTES,
|
|
1054
|
+
f"Config file exceeds maximum size of {CONFIG_MAX_BYTES} bytes: {config_path}",
|
|
1055
|
+
)
|
|
1056
|
+
text = content.decode("utf-8")
|
|
1057
|
+
except HookError:
|
|
1058
|
+
raise
|
|
928
1059
|
except UnicodeDecodeError as exc:
|
|
929
1060
|
raise HookError(f"Config file is not valid UTF-8: {config_path}") from exc
|
|
930
1061
|
except OSError as exc:
|
|
1062
|
+
if exc.errno in {errno.ELOOP, errno.EMLINK}:
|
|
1063
|
+
raise HookError(
|
|
1064
|
+
f"Config file must not be a symlink or reparse point: {config_path}"
|
|
1065
|
+
) from exc
|
|
1066
|
+
if exc.errno == errno.ENOENT:
|
|
1067
|
+
raise HookError(
|
|
1068
|
+
"Missing required config file `ai-push-hooks.toml` in repo root. "
|
|
1069
|
+
"Run `ai-push-hooks init --template minimal-docs` first"
|
|
1070
|
+
) from exc
|
|
931
1071
|
raise HookError(f"Could not read config file {config_path}: {exc}") from exc
|
|
1072
|
+
finally:
|
|
1073
|
+
if descriptor >= 0:
|
|
1074
|
+
os.close(descriptor)
|
|
932
1075
|
try:
|
|
933
1076
|
loaded = tomllib.loads(text)
|
|
934
1077
|
except ValueError as exc:
|
|
@@ -937,7 +1080,9 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
|
|
|
937
1080
|
location = f" at line {exc.lineno}, column {exc.colno}"
|
|
938
1081
|
raise HookError(f"Invalid TOML in {config_path}{location}: {exc}") from exc
|
|
939
1082
|
if not isinstance(loaded, dict):
|
|
940
|
-
raise HookError(
|
|
1083
|
+
raise HookError(
|
|
1084
|
+
f"Invalid config format in {config_path}: expected a top-level table"
|
|
1085
|
+
)
|
|
941
1086
|
model_override = os.getenv("AI_PUSH_HOOKS_MODEL")
|
|
942
1087
|
variant_override = os.getenv("AI_PUSH_HOOKS_VARIANT")
|
|
943
1088
|
_validate_model_override(model_override)
|
|
@@ -953,12 +1098,18 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
|
|
|
953
1098
|
if step.prompt and step.prompt.strip():
|
|
954
1099
|
return step.prompt.strip()
|
|
955
1100
|
if step.prompt_file:
|
|
956
|
-
parts = relative_path_parts(
|
|
1101
|
+
parts = relative_path_parts(
|
|
1102
|
+
step.prompt_file, f"Prompt file for step `{step.id}`"
|
|
1103
|
+
)
|
|
957
1104
|
if any(normalized_component(part) == ".git" for part in parts):
|
|
958
|
-
raise HookError(
|
|
1105
|
+
raise HookError(
|
|
1106
|
+
f"Prompt file for step `{step.id}` must not reference Git metadata"
|
|
1107
|
+
)
|
|
959
1108
|
lexical_prompt_path = repo_root.joinpath(*parts)
|
|
960
1109
|
if path_has_symlink(repo_root, lexical_prompt_path):
|
|
961
|
-
raise HookError(
|
|
1110
|
+
raise HookError(
|
|
1111
|
+
f"Prompt file for step `{step.id}` must not traverse a symlink"
|
|
1112
|
+
)
|
|
962
1113
|
prompt_path = resolve_contained_path(
|
|
963
1114
|
repo_root,
|
|
964
1115
|
step.prompt_file,
|
|
@@ -972,10 +1123,19 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
|
|
|
972
1123
|
)
|
|
973
1124
|
except HookError:
|
|
974
1125
|
git_roots = ()
|
|
975
|
-
if any(
|
|
976
|
-
|
|
1126
|
+
if any(
|
|
1127
|
+
is_path_within(resolved_prompt_path, git_root) for git_root in git_roots
|
|
1128
|
+
):
|
|
1129
|
+
raise HookError(
|
|
1130
|
+
f"Prompt file for step `{step.id}` must not resolve inside Git metadata"
|
|
1131
|
+
)
|
|
977
1132
|
if prompt_path.exists():
|
|
978
|
-
flags =
|
|
1133
|
+
flags = (
|
|
1134
|
+
os.O_RDONLY
|
|
1135
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
1136
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
1137
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
1138
|
+
)
|
|
979
1139
|
try:
|
|
980
1140
|
descriptor = os.open(prompt_path, flags)
|
|
981
1141
|
except OSError as exc:
|
|
@@ -987,9 +1147,18 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
|
|
|
987
1147
|
raise HookError(
|
|
988
1148
|
f"Prompt file is not a regular file for step `{step.id}`: {prompt_path}"
|
|
989
1149
|
)
|
|
990
|
-
|
|
991
|
-
descriptor
|
|
992
|
-
|
|
1150
|
+
content = _read_descriptor_limited(
|
|
1151
|
+
descriptor,
|
|
1152
|
+
PROMPT_MAX_BYTES,
|
|
1153
|
+
f"Prompt file exceeds maximum size of {PROMPT_MAX_BYTES} bytes "
|
|
1154
|
+
f"for step `{step.id}`: {prompt_path}",
|
|
1155
|
+
)
|
|
1156
|
+
try:
|
|
1157
|
+
text = content.decode("utf-8").strip()
|
|
1158
|
+
except UnicodeDecodeError as exc:
|
|
1159
|
+
raise HookError(
|
|
1160
|
+
f"Prompt file is not valid UTF-8 for step `{step.id}`: {prompt_path}"
|
|
1161
|
+
) from exc
|
|
993
1162
|
finally:
|
|
994
1163
|
if descriptor >= 0:
|
|
995
1164
|
os.close(descriptor)
|
|
@@ -997,7 +1166,9 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
|
|
|
997
1166
|
return text
|
|
998
1167
|
if step.fallback_prompt_id:
|
|
999
1168
|
return resolve_builtin_prompt(step.fallback_prompt_id)
|
|
1000
|
-
raise HookError(
|
|
1169
|
+
raise HookError(
|
|
1170
|
+
f"Prompt file not found or empty for step `{step.id}`: {prompt_path}"
|
|
1171
|
+
)
|
|
1001
1172
|
if step.fallback_prompt_id:
|
|
1002
1173
|
return resolve_builtin_prompt(step.fallback_prompt_id)
|
|
1003
1174
|
raise HookError(f"No prompt source available for step `{step.id}`")
|