ai-push-hooks 0.1.19 → 0.2.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/pyproject.toml CHANGED
@@ -4,25 +4,32 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-push-hooks"
7
- version = "0.1.19"
8
- description = "Modular AI push-hook workflow runner"
7
+ version = "0.2.0"
8
+ description = "Run structured AI-assisted checks and allowlisted maintenance before git push"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
- authors = [{ name = "ai-push-hooks contributors" }]
12
+ authors = [{ name = "Shane Bishop" }]
13
13
  keywords = ["git", "lefthook", "docs", "ai", "pre-push"]
14
14
  classifiers = [
15
- "Development Status :: 3 - Alpha",
15
+ "Development Status :: 4 - Beta",
16
16
  "Intended Audience :: Developers",
17
17
  "Programming Language :: Python :: 3",
18
18
  "Programming Language :: Python :: 3 :: Only",
19
19
  "Programming Language :: Python :: 3.10",
20
20
  "Programming Language :: Python :: 3.11",
21
21
  "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
22
23
  "Topic :: Software Development :: Version Control :: Git",
23
24
  ]
24
25
  dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
25
26
 
27
+ [project.urls]
28
+ Homepage = "https://github.com/shanebishop1/ai-push-hooks"
29
+ Repository = "https://github.com/shanebishop1/ai-push-hooks.git"
30
+ Issues = "https://github.com/shanebishop1/ai-push-hooks/issues"
31
+ Changelog = "https://github.com/shanebishop1/ai-push-hooks/blob/main/CHANGELOG.md"
32
+
26
33
  [project.scripts]
27
34
  ai-push-hooks = "ai_push_hooks.cli:main"
28
35
 
@@ -6,6 +6,15 @@ from datetime import datetime, timezone
6
6
  from typing import Any
7
7
  from uuid import uuid4
8
8
 
9
+ from .paths import (
10
+ ensure_private_directory,
11
+ is_path_within,
12
+ path_has_symlink,
13
+ path_is_link_or_reparse,
14
+ resolve_contained_path,
15
+ validate_path_component,
16
+ write_text_no_follow,
17
+ )
9
18
  from .types import HookError, ModuleRuntimeState
10
19
 
11
20
 
@@ -19,13 +28,42 @@ class ArtifactStore:
19
28
  self.run_dir = run_dir
20
29
 
21
30
  def prepare(self) -> pathlib.Path:
22
- self.run_dir.mkdir(parents=True, exist_ok=True)
23
- return self.run_dir
31
+ if path_is_link_or_reparse(self.run_dir):
32
+ raise HookError(
33
+ f"Artifact run directory must not be a symlink or reparse point: {self.run_dir}"
34
+ )
35
+ return ensure_private_directory(self.run_dir)
24
36
 
25
37
  def step_dir(self, module_id: str, step_index: int, step_id: str) -> pathlib.Path:
26
- path = self.run_dir / module_id / f"{step_index:02d}-{step_id}"
27
- path.mkdir(parents=True, exist_ok=True)
28
- return path
38
+ module_name = validate_path_component(module_id, "Artifact module id")
39
+ step_name = validate_path_component(step_id, "Artifact step id")
40
+ lexical_module_path = self.run_dir / module_name
41
+ if path_has_symlink(self.run_dir, lexical_module_path):
42
+ raise HookError(f"Artifact module path must not traverse a symlink: {module_id}")
43
+ module_path = resolve_contained_path(self.run_dir, module_name, "Artifact module path")
44
+ lexical_step_path = module_path / f"{step_index:02d}-{step_name}"
45
+ if path_has_symlink(self.run_dir, lexical_step_path):
46
+ raise HookError(f"Artifact step path must not traverse a symlink: {step_id}")
47
+ path = resolve_contained_path(
48
+ module_path,
49
+ f"{step_index:02d}-{step_name}",
50
+ "Artifact step path",
51
+ )
52
+ return ensure_private_directory(path)
53
+
54
+ def _artifact_path(
55
+ self,
56
+ module_id: str,
57
+ step_index: int,
58
+ step_id: str,
59
+ artifact_name: str,
60
+ ) -> pathlib.Path:
61
+ name = validate_path_component(artifact_name, "Artifact name")
62
+ return resolve_contained_path(
63
+ self.step_dir(module_id, step_index, step_id),
64
+ name,
65
+ "Artifact output path",
66
+ )
29
67
 
30
68
  def register(
31
69
  self,
@@ -34,6 +72,13 @@ class ArtifactStore:
34
72
  artifact_name: str,
35
73
  path: pathlib.Path,
36
74
  ) -> pathlib.Path:
75
+ validate_path_component(step_id, "Artifact step id")
76
+ validate_path_component(artifact_name, "Artifact name")
77
+ resolved_run_dir = self.run_dir.resolve(strict=False)
78
+ if path_has_symlink(self.run_dir, path):
79
+ raise HookError(f"Artifact path must not traverse a symlink: {path}")
80
+ if not is_path_within(path.resolve(strict=False), resolved_run_dir):
81
+ raise HookError(f"Artifact path escapes run directory: {path}")
37
82
  state.artifacts[f"{step_id}/{artifact_name}"] = path
38
83
  return path
39
84
 
@@ -45,8 +90,8 @@ class ArtifactStore:
45
90
  artifact_name: str,
46
91
  content: str,
47
92
  ) -> pathlib.Path:
48
- path = self.step_dir(state.module.id, step_index, step_id) / artifact_name
49
- path.write_text(content, encoding="utf-8")
93
+ path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
94
+ write_text_no_follow(path, content)
50
95
  return self.register(state, step_id, artifact_name, path)
51
96
 
52
97
  def write_json(
@@ -57,8 +102,8 @@ class ArtifactStore:
57
102
  artifact_name: str,
58
103
  payload: Any,
59
104
  ) -> pathlib.Path:
60
- path = self.step_dir(state.module.id, step_index, step_id) / artifact_name
61
- path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
105
+ path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
106
+ write_text_no_follow(path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n")
62
107
  return self.register(state, step_id, artifact_name, path)
63
108
 
64
109
  def resolve_input(self, state: ModuleRuntimeState, reference: str) -> pathlib.Path:
@@ -91,4 +136,7 @@ class ArtifactStore:
91
136
  artifact_name: str,
92
137
  path: pathlib.Path,
93
138
  ) -> None:
139
+ validate_path_component(module_id, "Artifact module id")
140
+ validate_path_component(step_id, "Artifact step id")
141
+ validate_path_component(artifact_name, "Artifact name")
94
142
  state.artifacts[f"{module_id}:{step_id}/{artifact_name}"] = path
@@ -1,10 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import os
4
5
  import pathlib
6
+ import stat
5
7
  import sys
6
8
 
7
9
  from .hook import run_hook
10
+ from .install import install_hook
11
+ from .paths import path_is_link_or_reparse, write_text_no_follow
8
12
  from .prompts_builtin import MINIMAL_DOCS_TEMPLATE
9
13
  from .types import HookError
10
14
 
@@ -20,6 +24,11 @@ def _build_parser() -> argparse.ArgumentParser:
20
24
  init_parser = subparsers.add_parser("init", help="Write a starter config")
21
25
  init_parser.add_argument("--template", default="minimal-docs")
22
26
  init_parser.add_argument("--force", action="store_true")
27
+
28
+ install_parser = subparsers.add_parser(
29
+ "install", help="Install a repo-local pre-push hook"
30
+ )
31
+ install_parser.add_argument("--force", action="store_true")
23
32
  return parser
24
33
 
25
34
 
@@ -28,9 +37,55 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
28
37
  raise HookError("Only `minimal-docs` is supported")
29
38
  target_dir = cwd or pathlib.Path.cwd()
30
39
  config_path = target_dir / "ai-push-hooks.toml"
31
- if config_path.exists() and not force:
32
- raise HookError(f"Refusing to overwrite existing config without --force: {config_path}")
33
- config_path.write_text(MINIMAL_DOCS_TEMPLATE, encoding="utf-8")
40
+ if force:
41
+ try:
42
+ metadata = config_path.lstat()
43
+ except FileNotFoundError:
44
+ metadata = None
45
+ except OSError as exc:
46
+ raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
47
+ if metadata is not None:
48
+ if path_is_link_or_reparse(config_path):
49
+ raise HookError(f"Refusing to replace symlink or reparse point: {config_path}")
50
+ if not stat.S_ISREG(metadata.st_mode):
51
+ raise HookError(f"Refusing to replace non-regular config path: {config_path}")
52
+ try:
53
+ write_text_no_follow(config_path, MINIMAL_DOCS_TEMPLATE)
54
+ except HookError:
55
+ raise
56
+ except OSError as exc:
57
+ raise HookError(f"Could not write config file {config_path}: {exc}") from exc
58
+ else:
59
+ try:
60
+ metadata = config_path.lstat()
61
+ except FileNotFoundError:
62
+ metadata = None
63
+ except OSError as exc:
64
+ raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
65
+ if metadata is not None:
66
+ if path_is_link_or_reparse(config_path):
67
+ raise HookError(f"Refusing to overwrite symlink or reparse point: {config_path}")
68
+ if not stat.S_ISREG(metadata.st_mode):
69
+ raise HookError(f"Refusing to overwrite non-regular config path: {config_path}")
70
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
71
+ flags |= getattr(os, "O_NOFOLLOW", 0)
72
+ try:
73
+ descriptor = os.open(config_path, flags, 0o600)
74
+ except FileExistsError as exc:
75
+ raise HookError(
76
+ f"Refusing to overwrite existing config without --force: {config_path}"
77
+ ) from exc
78
+ except OSError as exc:
79
+ raise HookError(f"Could not create config file {config_path}: {exc}") from exc
80
+ try:
81
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
82
+ descriptor = -1
83
+ handle.write(MINIMAL_DOCS_TEMPLATE)
84
+ handle.flush()
85
+ os.fsync(handle.fileno())
86
+ finally:
87
+ if descriptor >= 0:
88
+ os.close(descriptor)
34
89
  sys.stdout.write(str(config_path) + "\n")
35
90
  return 0
36
91
 
@@ -43,6 +98,8 @@ def main(argv: list[str] | None = None) -> int:
43
98
  return run_hook(args.remote_name, args.remote_url)
44
99
  if args.command == "init":
45
100
  return init_config(args.template, args.force)
101
+ if args.command == "install":
102
+ return install_hook(args.force)
46
103
  raise HookError(f"Unknown command: {args.command}")
47
104
  except HookError as exc:
48
105
  sys.stderr.write(f"[ai-push-hooks] {exc}\n")
@@ -2,9 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import pathlib
5
+ import stat
5
6
  from typing import Any
6
7
 
7
- from .executors.exec import env_bool
8
+ from .executors.exec import env_bool, resolve_git_common_dir, resolve_git_dir
9
+ from .paths import (
10
+ is_path_within,
11
+ normalized_component,
12
+ path_has_symlink,
13
+ relative_path_parts,
14
+ resolve_contained_path,
15
+ validate_path_component,
16
+ )
8
17
  from .prompts_builtin import BUILTIN_PROMPTS
9
18
  from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
10
19
 
@@ -14,6 +23,173 @@ except ModuleNotFoundError: # pragma: no cover
14
23
  import tomli as tomllib
15
24
 
16
25
  ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
26
+ STORAGE_NAMESPACE_PARTS = (".git", "ai-push-hooks")
27
+ GENERAL_KEYS = {
28
+ "enabled",
29
+ "allow_push_on_error",
30
+ "require_clean_worktree",
31
+ "skip_on_sync_branch",
32
+ "base_branch",
33
+ }
34
+ LLM_KEYS = {
35
+ "runner",
36
+ "model",
37
+ "variant",
38
+ "timeout_seconds",
39
+ "max_parallel",
40
+ "json_max_retries",
41
+ "invalid_json_feedback_max_chars",
42
+ "json_retry_new_session",
43
+ "delete_session_after_run",
44
+ "max_diff_bytes",
45
+ "session_title_prefix",
46
+ }
47
+ LOGGING_KEYS = {
48
+ "level",
49
+ "jsonl",
50
+ "dir",
51
+ "capture_llm_transcript",
52
+ "transcript_dir",
53
+ "summary_dir",
54
+ "print_llm_output",
55
+ }
56
+ STEP_KEYS = {
57
+ "id",
58
+ "type",
59
+ "inputs",
60
+ "output",
61
+ "schema",
62
+ "prompt",
63
+ "prompt_file",
64
+ "fallback_prompt_id",
65
+ "collector",
66
+ "allow_paths",
67
+ "executor",
68
+ "assertion",
69
+ "when_env",
70
+ }
71
+
72
+
73
+ def _require_table(value: Any, label: str) -> dict[str, Any]:
74
+ if not isinstance(value, dict):
75
+ raise HookError(f"{label} must be a table")
76
+ return value
77
+
78
+
79
+ def _validate_unknown_keys(table: dict[str, Any], allowed: set[str], label: str) -> None:
80
+ unknown = set(table) - allowed
81
+ if unknown:
82
+ raise HookError(f"Unknown field(s) in {label}: {', '.join(sorted(unknown))}")
83
+
84
+
85
+ def _validate_bool(table: dict[str, Any], key: str, label: str) -> None:
86
+ if key in table and type(table[key]) is not bool:
87
+ raise HookError(f"{label}.{key} must be a TOML boolean")
88
+
89
+
90
+ def _validate_string(
91
+ table: dict[str, Any], key: str, label: str, *, allow_none: bool = False
92
+ ) -> None:
93
+ if key in table and (table[key] is None and allow_none):
94
+ return
95
+ if key in table and not isinstance(table[key], str):
96
+ raise HookError(f"{label}.{key} must be a string")
97
+
98
+
99
+ def _validate_string_list(table: dict[str, Any], key: str, label: str) -> None:
100
+ if key not in table:
101
+ return
102
+ value = table[key]
103
+ if not isinstance(value, (list, tuple)) or any(
104
+ not isinstance(item, str) for item in value
105
+ ):
106
+ raise HookError(f"{label}.{key} must be an array of strings")
107
+
108
+
109
+ def _validate_integer(
110
+ table: dict[str, Any], key: str, label: str, *, minimum: int | None = None
111
+ ) -> None:
112
+ if key not in table:
113
+ return
114
+ value = table[key]
115
+ if type(value) is not int:
116
+ raise HookError(f"{label}.{key} must be an integer")
117
+ if minimum is not None and value < minimum:
118
+ raise HookError(f"{label}.{key} must be at least {minimum}")
119
+
120
+
121
+ def _validate_config_types(raw: dict[str, Any]) -> None:
122
+ unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
123
+ if unknown:
124
+ raise HookError(
125
+ "Legacy or unsupported config keys are not allowed: " + ", ".join(sorted(unknown))
126
+ )
127
+
128
+ general = _require_table(raw.get("general", {}), "general")
129
+ _validate_unknown_keys(general, GENERAL_KEYS, "general")
130
+ for key in (
131
+ "enabled",
132
+ "allow_push_on_error",
133
+ "require_clean_worktree",
134
+ "skip_on_sync_branch",
135
+ ):
136
+ _validate_bool(general, key, "general")
137
+ _validate_string(general, "base_branch", "general")
138
+
139
+ llm = _require_table(raw.get("llm", {}), "llm")
140
+ _validate_unknown_keys(llm, LLM_KEYS, "llm")
141
+ for key in ("runner", "model", "variant", "session_title_prefix"):
142
+ _validate_string(llm, key, "llm")
143
+ for key in ("json_retry_new_session", "delete_session_after_run"):
144
+ _validate_bool(llm, key, "llm")
145
+ _validate_integer(llm, "timeout_seconds", "llm", minimum=1)
146
+ _validate_integer(llm, "max_parallel", "llm", minimum=1)
147
+ _validate_integer(llm, "json_max_retries", "llm", minimum=0)
148
+ _validate_integer(llm, "invalid_json_feedback_max_chars", "llm", minimum=1)
149
+ _validate_integer(llm, "max_diff_bytes", "llm", minimum=1)
150
+
151
+ logging = _require_table(raw.get("logging", {}), "logging")
152
+ _validate_unknown_keys(logging, LOGGING_KEYS, "logging")
153
+ for key in ("level", "dir", "transcript_dir", "summary_dir"):
154
+ _validate_string(logging, key, "logging")
155
+ for key in ("jsonl", "capture_llm_transcript", "print_llm_output"):
156
+ _validate_bool(logging, key, "logging")
157
+
158
+ workflow = _require_table(raw.get("workflow", {}), "workflow")
159
+ _validate_unknown_keys(workflow, {"modules"}, "workflow")
160
+ _validate_string_list(workflow, "modules", "workflow")
161
+
162
+ modules = _require_table(raw.get("modules", {}), "modules")
163
+ for module_id, module_value in modules.items():
164
+ if not isinstance(module_id, str):
165
+ raise HookError("modules keys must be strings")
166
+ module = _require_table(module_value, f"modules.{module_id}")
167
+ _validate_unknown_keys(module, {"enabled", "steps"}, f"modules.{module_id}")
168
+ _validate_bool(module, "enabled", f"modules.{module_id}")
169
+ if "steps" in module:
170
+ steps = module["steps"]
171
+ if not isinstance(steps, (list, tuple)):
172
+ raise HookError(f"modules.{module_id}.steps must be an array of tables")
173
+ for index, step_value in enumerate(steps, start=1):
174
+ step = _require_table(step_value, f"modules.{module_id}.steps[{index}]")
175
+ label = f"modules.{module_id}.steps[{index}]"
176
+ _validate_unknown_keys(step, STEP_KEYS, label)
177
+ for key in ("id", "type"):
178
+ _validate_string(step, key, label)
179
+ for key in (
180
+ "collector",
181
+ "executor",
182
+ "assertion",
183
+ "output",
184
+ "schema",
185
+ "prompt",
186
+ "prompt_file",
187
+ "fallback_prompt_id",
188
+ "when_env",
189
+ ):
190
+ _validate_string(step, key, label, allow_none=True)
191
+ for key in ("inputs", "allow_paths"):
192
+ _validate_string_list(step, key, label)
17
193
 
18
194
 
19
195
  def _normalize_step(raw: dict[str, Any]) -> StepConfig:
@@ -41,6 +217,15 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
41
217
  )
42
218
  if not step.id:
43
219
  raise HookError("Every workflow step requires a non-empty id")
220
+ validate_path_component(step.id, "Workflow step id")
221
+ if step.output:
222
+ validate_path_component(step.output, f"Output for step `{step.id}`")
223
+ for pattern in step.allow_paths:
224
+ parts = relative_path_parts(pattern, f"allow_paths entry for step `{step.id}`")
225
+ if any(normalized_component(part) == ".git" for part in parts):
226
+ raise HookError(f"Apply step `{step.id}` may not allow Git metadata paths")
227
+ if normalized_component(parts[-1]) == "agents.md":
228
+ raise HookError(f"Apply step `{step.id}` may not allow AGENTS.md")
44
229
  if step.is_promptable and not any([step.prompt, step.prompt_file, step.fallback_prompt_id]):
45
230
  raise HookError(f"Promptable step `{step.id}` requires prompt, prompt_file, or fallback_prompt_id")
46
231
  if step.type == "collect" and not step.collector:
@@ -57,6 +242,9 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
57
242
 
58
243
 
59
244
  def _build_config(raw: dict[str, Any]) -> HookConfig:
245
+ if not isinstance(raw, dict):
246
+ raise HookError("Config document must contain a top-level table")
247
+ _validate_config_types(raw)
60
248
  unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
61
249
  if unknown:
62
250
  raise HookError(
@@ -73,6 +261,7 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
73
261
 
74
262
  modules: dict[str, ModuleConfig] = {}
75
263
  for module_id in workflow_modules:
264
+ validate_path_component(module_id, "Workflow module id")
76
265
  if module_id not in module_payload:
77
266
  raise HookError(f"workflow.modules references unknown module `{module_id}`")
78
267
  module_raw = module_payload[module_id]
@@ -88,6 +277,14 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
88
277
  general = GeneralConfig(**raw.get("general", {}))
89
278
  llm = LlmConfig(**raw.get("llm", {}))
90
279
  logging = LoggingConfig(**raw.get("logging", {}))
280
+ for label, storage_path in (
281
+ ("logging.dir", logging.dir),
282
+ ("logging.transcript_dir", logging.transcript_dir),
283
+ ("logging.summary_dir", logging.summary_dir),
284
+ ):
285
+ parts = relative_path_parts(storage_path, label)
286
+ if parts[:2] != STORAGE_NAMESPACE_PARTS or len(parts) < 3:
287
+ raise HookError(f"{label} must be inside .git/ai-push-hooks/")
91
288
  return HookConfig(
92
289
  general=general,
93
290
  llm=llm,
@@ -117,16 +314,27 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
117
314
  "steps": [step.__dict__.copy() for step in module.steps],
118
315
  }
119
316
 
120
- skip = env_bool("AI_PUSH_HOOKS_SKIP")
317
+ def read_env_bool(name: str) -> bool | None:
318
+ value = os.getenv(name)
319
+ if value is None:
320
+ return None
321
+ parsed = env_bool(name)
322
+ if parsed is None:
323
+ raise HookError(
324
+ f"Invalid boolean environment override {name}: expected true/false"
325
+ )
326
+ return parsed
327
+
328
+ skip = read_env_bool("AI_PUSH_HOOKS_SKIP")
121
329
  if skip is True:
122
330
  raw["general"]["enabled"] = False
123
- allow_on_error = env_bool("AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR")
331
+ allow_on_error = read_env_bool("AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR")
124
332
  if allow_on_error is not None:
125
333
  raw["general"]["allow_push_on_error"] = allow_on_error
126
- require_clean = env_bool("AI_PUSH_HOOKS_REQUIRE_CLEAN")
334
+ require_clean = read_env_bool("AI_PUSH_HOOKS_REQUIRE_CLEAN")
127
335
  if require_clean is not None:
128
336
  raw["general"]["require_clean_worktree"] = require_clean
129
- allow_dirty = env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
337
+ allow_dirty = read_env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
130
338
  if allow_dirty is True:
131
339
  raw["general"]["require_clean_worktree"] = False
132
340
  base_branch = os.getenv("AI_PUSH_HOOKS_BASE_BRANCH")
@@ -136,7 +344,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
136
344
  logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
137
345
  if logging_level:
138
346
  raw["logging"]["level"] = logging_level.strip().lower()
139
- print_output = env_bool("AI_PUSH_HOOKS_PRINT_LLM_OUTPUT")
347
+ print_output = read_env_bool("AI_PUSH_HOOKS_PRINT_LLM_OUTPUT")
140
348
  if print_output is not None:
141
349
  raw["logging"]["print_llm_output"] = print_output
142
350
  model = os.getenv("AI_PUSH_HOOKS_MODEL")
@@ -146,8 +354,20 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
146
354
  if variant is not None:
147
355
  raw["llm"]["variant"] = variant.strip()
148
356
  timeout = os.getenv("AI_PUSH_HOOKS_TIMEOUT_SECONDS")
149
- if timeout:
150
- raw["llm"]["timeout_seconds"] = int(timeout)
357
+ if timeout is not None:
358
+ try:
359
+ parsed_timeout = int(timeout.strip())
360
+ except ValueError as exc:
361
+ raise HookError(
362
+ "Invalid numeric environment override AI_PUSH_HOOKS_TIMEOUT_SECONDS: "
363
+ f"{timeout!r}"
364
+ ) from exc
365
+ if parsed_timeout < 1:
366
+ raise HookError(
367
+ "Invalid numeric environment override AI_PUSH_HOOKS_TIMEOUT_SECONDS: "
368
+ "must be at least 1"
369
+ )
370
+ raw["llm"]["timeout_seconds"] = parsed_timeout
151
371
  return _build_config(raw)
152
372
 
153
373
 
@@ -158,10 +378,21 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
158
378
  "Missing required config file `ai-push-hooks.toml` in repo root. "
159
379
  "Run `ai-push-hooks init --template minimal-docs` first"
160
380
  )
161
- text = config_path.read_text(encoding="utf-8")
162
- loaded = tomllib.loads(text)
381
+ try:
382
+ text = config_path.read_text(encoding="utf-8")
383
+ except UnicodeDecodeError as exc:
384
+ raise HookError(f"Config file is not valid UTF-8: {config_path}") from exc
385
+ except OSError as exc:
386
+ raise HookError(f"Could not read config file {config_path}: {exc}") from exc
387
+ try:
388
+ loaded = tomllib.loads(text)
389
+ except ValueError as exc:
390
+ location = ""
391
+ if hasattr(exc, "lineno") and hasattr(exc, "colno"):
392
+ location = f" at line {exc.lineno}, column {exc.colno}"
393
+ raise HookError(f"Invalid TOML in {config_path}{location}: {exc}") from exc
163
394
  if not isinstance(loaded, dict):
164
- raise HookError(f"Invalid config format in {config_path}")
395
+ raise HookError(f"Invalid config format in {config_path}: expected a top-level table")
165
396
  return _apply_env_overrides(_build_config(loaded)), config_path
166
397
 
167
398
 
@@ -169,11 +400,46 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
169
400
  if step.prompt and step.prompt.strip():
170
401
  return step.prompt.strip()
171
402
  if step.prompt_file:
172
- prompt_path = pathlib.Path(step.prompt_file)
173
- if not prompt_path.is_absolute():
174
- prompt_path = (repo_root / prompt_path).resolve()
403
+ parts = relative_path_parts(step.prompt_file, f"Prompt file for step `{step.id}`")
404
+ if any(normalized_component(part) == ".git" for part in parts):
405
+ raise HookError(f"Prompt file for step `{step.id}` must not reference Git metadata")
406
+ lexical_prompt_path = repo_root.joinpath(*parts)
407
+ if path_has_symlink(repo_root, lexical_prompt_path):
408
+ raise HookError(f"Prompt file for step `{step.id}` must not traverse a symlink")
409
+ prompt_path = resolve_contained_path(
410
+ repo_root,
411
+ step.prompt_file,
412
+ f"Prompt file for step `{step.id}`",
413
+ )
414
+ resolved_prompt_path = prompt_path.resolve(strict=False)
415
+ try:
416
+ git_roots = (
417
+ resolve_git_dir(repo_root).resolve(strict=True),
418
+ resolve_git_common_dir(repo_root).resolve(strict=True),
419
+ )
420
+ except HookError:
421
+ git_roots = ()
422
+ if any(is_path_within(resolved_prompt_path, git_root) for git_root in git_roots):
423
+ raise HookError(f"Prompt file for step `{step.id}` must not resolve inside Git metadata")
175
424
  if prompt_path.exists():
176
- text = prompt_path.read_text(encoding="utf-8").strip()
425
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
426
+ try:
427
+ descriptor = os.open(prompt_path, flags)
428
+ except OSError as exc:
429
+ raise HookError(
430
+ f"Prompt file could not be opened safely for step `{step.id}`: {prompt_path}"
431
+ ) from exc
432
+ try:
433
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
434
+ raise HookError(
435
+ f"Prompt file is not a regular file for step `{step.id}`: {prompt_path}"
436
+ )
437
+ with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
438
+ descriptor = -1
439
+ text = handle.read().strip()
440
+ finally:
441
+ if descriptor >= 0:
442
+ os.close(descriptor)
177
443
  if text:
178
444
  return text
179
445
  if step.fallback_prompt_id:
@@ -64,8 +64,6 @@ class WorkflowEngine:
64
64
  continue
65
65
  if any(not running_step.is_read_only for _future, (_state, running_step) in futures.items()):
66
66
  continue
67
- if not step.is_read_only and futures:
68
- continue
69
67
  if step.is_read_only and len(futures) >= max(1, self.context.config.llm.max_parallel):
70
68
  continue
71
69
  future = pool.submit(self._execute_step, state, step)