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.
Files changed (36) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/README.md +90 -82
  3. package/SECURITY.md +7 -2
  4. package/ai-push-hooks.toml +1 -1
  5. package/docs/configuration.md +339 -0
  6. package/package.json +3 -3
  7. package/pyproject.toml +12 -3
  8. package/src/ai_push_hooks/artifacts.py +19 -6
  9. package/src/ai_push_hooks/cli.py +24 -8
  10. package/src/ai_push_hooks/config.py +220 -49
  11. package/src/ai_push_hooks/engine.py +93 -43
  12. package/src/ai_push_hooks/executors/apply.py +121 -43
  13. package/src/ai_push_hooks/executors/ask.py +30 -13
  14. package/src/ai_push_hooks/executors/exec.py +61 -22
  15. package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
  16. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  17. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  18. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  19. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  20. package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
  22. package/src/ai_push_hooks/executors/runners/process.py +26 -7
  23. package/src/ai_push_hooks/executors/runners/registry.py +14 -5
  24. package/src/ai_push_hooks/executors/step_commands.py +58 -18
  25. package/src/ai_push_hooks/git_utils.py +92 -27
  26. package/src/ai_push_hooks/hook.py +48 -12
  27. package/src/ai_push_hooks/install.py +40 -18
  28. package/src/ai_push_hooks/modules/beads.py +18 -7
  29. package/src/ai_push_hooks/modules/docs.py +17 -7
  30. package/src/ai_push_hooks/modules/pr.py +18 -7
  31. package/src/ai_push_hooks/paths.py +6 -2
  32. package/src/ai_push_hooks/plugin_loader.py +79 -26
  33. package/src/ai_push_hooks/plugins.py +3 -1
  34. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  35. package/src/ai_push_hooks/types.py +47 -27
  36. 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(table: dict[str, Any], allowed: set[str], label: str) -> None:
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(f"Unknown placeholder {placeholder!r} in {argument_label}")
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(f"{label}.command must not contain {{prompt}} with stdin transport")
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(f"{label}.command uses {{model}} but {label}.model is not configured")
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(f"{label}.type must be one of: {', '.join(sorted(RUNNER_TYPES))}")
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 "project_access" in profile and profile["project_access"] not in PROJECT_ACCESS_VALUES:
231
- raise HookError(f"{label}.project_access must be one of: artifacts, project")
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(f"{label}.{key} is only valid for runner type {('opencode' if key == 'variant' else 'command')}")
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(f"{label} path must reference an existing regular file") from exc
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: " + ", ".join(sorted(unknown))
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 "runner" in step and step["runner"] is not None and not step["runner"].strip():
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(f"{label}.runner is only valid on ask and apply steps")
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() if raw.get("output") is not None else None,
598
- schema=str(raw.get("schema")).strip() if raw.get("schema") is not None else None,
599
- prompt=str(raw.get("prompt")).strip() if raw.get("prompt") is not None else None,
600
- prompt_file=str(raw.get("prompt_file")).strip() if raw.get("prompt_file") is not None else None,
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() if raw.get("collector") is not None else None,
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() if raw.get("executor") is not None else None,
609
- assertion=str(raw.get("assertion")).strip() if raw.get("assertion") is not None else None,
610
- python=str(raw.get("python")).strip() if raw.get("python") is not None else None,
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() if raw.get("when_env") is not None else None,
620
- runner=str(raw.get("runner")).strip() if raw.get("runner") is not None else None,
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([step.prompt, step.prompt_file, step.fallback_prompt_id]):
634
- raise HookError(f"Promptable step `{step.id}` requires prompt, prompt_file, or fallback_prompt_id")
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(f"Assert step `{step.id}` requires assertion, python, or command")
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], *, effective_model: str | None = None,
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: " + ", ".join(sorted(unknown))
753
+ "Legacy or unsupported config keys are not allowed: "
754
+ + ", ".join(sorted(unknown))
660
755
  )
661
756
 
662
- workflow_modules = tuple(str(item) for item in raw.get("workflow", {}).get("modules", []) or [])
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(module_raw.get("steps", []) or [], start=1):
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
- "modules": {},
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
- if not config_path.exists():
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
- text = config_path.read_text(encoding="utf-8")
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(f"Invalid config format in {config_path}: expected a top-level table")
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(step.prompt_file, f"Prompt file for step `{step.id}`")
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(f"Prompt file for step `{step.id}` must not reference Git metadata")
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(f"Prompt file for step `{step.id}` must not traverse a symlink")
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(is_path_within(resolved_prompt_path, git_root) for git_root in git_roots):
976
- raise HookError(f"Prompt file for step `{step.id}` must not resolve inside Git metadata")
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 = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
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
- with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
991
- descriptor = -1
992
- text = handle.read().strip()
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(f"Prompt file not found or empty for step `{step.id}`: {prompt_path}")
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}`")