ai-push-hooks 0.1.18 → 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.
@@ -1,102 +1,195 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  import os
5
4
  import pathlib
6
- import re
5
+ import stat
7
6
  from typing import Any
8
7
 
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
+ )
9
17
  from .prompts_builtin import BUILTIN_PROMPTS
10
18
  from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
11
- from .executors.exec import env_bool
12
19
 
13
20
  try:
14
21
  import tomllib
15
22
  except ModuleNotFoundError: # pragma: no cover
16
- tomllib = None # type: ignore[assignment]
23
+ import tomli as tomllib
17
24
 
18
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
+ }
19
71
 
20
72
 
21
- def _parse_multiline_string(lines: list[str], index: int, initial: str) -> tuple[str, int]:
22
- chunks: list[str] = []
23
- value = initial[3:]
24
- while True:
25
- end_index = value.find('"""')
26
- if end_index >= 0:
27
- chunks.append(value[:end_index])
28
- return "\n".join(chunks), index
29
- chunks.append(value)
30
- index += 1
31
- if index >= len(lines):
32
- raise HookError("Unterminated multiline string in TOML fallback parser")
33
- value = lines[index]
34
-
35
-
36
- def _assign_path(root: dict[str, Any], path: list[str], value: Any, array_mode: bool = False) -> dict[str, Any]:
37
- current: Any = root
38
- for part in path[:-1]:
39
- if isinstance(current, list):
40
- if not current:
41
- current.append({})
42
- current = current[-1]
43
- current = current.setdefault(part, {})
44
- key = path[-1]
45
- if array_mode:
46
- items = current.setdefault(key, [])
47
- if not isinstance(items, list):
48
- raise HookError(f"Invalid array-of-table path: {'.'.join(path)}")
49
- item: dict[str, Any] = {}
50
- items.append(item)
51
- return item
52
- current[key] = value
53
- return current
54
-
55
-
56
- def _parse_scalar(raw: str) -> Any:
57
- raw = raw.strip()
58
- if raw.startswith('"') and raw.endswith('"'):
59
- return raw[1:-1]
60
- if raw in {"true", "false"}:
61
- return raw == "true"
62
- if re.fullmatch(r"-?\d+", raw):
63
- return int(raw)
64
- if raw.startswith("[") and raw.endswith("]"):
65
- return json.loads(raw)
66
- return raw
67
-
68
-
69
- def parse_toml_fallback(raw: str) -> dict[str, Any]:
70
- parsed: dict[str, Any] = {}
71
- lines = raw.splitlines()
72
- current: Any = parsed
73
- index = 0
74
- while index < len(lines):
75
- line = lines[index].strip()
76
- index += 1
77
- if not line or line.startswith("#"):
78
- continue
79
- if line.startswith("[[") and line.endswith("]]"):
80
- path = [part.strip() for part in line[2:-2].split(".") if part.strip()]
81
- current = _assign_path(parsed, path, None, array_mode=True)
82
- continue
83
- if line.startswith("[") and line.endswith("]"):
84
- path = [part.strip() for part in line[1:-1].split(".") if part.strip()]
85
- current = parsed
86
- for part in path:
87
- current = current.setdefault(part, {})
88
- continue
89
- if "=" not in line:
90
- continue
91
- key, value = line.split("=", 1)
92
- key = key.strip()
93
- value = value.strip()
94
- if value.startswith('"""'):
95
- parsed_value, index = _parse_multiline_string(lines, index - 1, value)
96
- else:
97
- parsed_value = _parse_scalar(value)
98
- current[key] = parsed_value
99
- return parsed
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)
100
193
 
101
194
 
102
195
  def _normalize_step(raw: dict[str, Any]) -> StepConfig:
@@ -124,6 +217,15 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
124
217
  )
125
218
  if not step.id:
126
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")
127
229
  if step.is_promptable and not any([step.prompt, step.prompt_file, step.fallback_prompt_id]):
128
230
  raise HookError(f"Promptable step `{step.id}` requires prompt, prompt_file, or fallback_prompt_id")
129
231
  if step.type == "collect" and not step.collector:
@@ -140,6 +242,9 @@ def _normalize_step(raw: dict[str, Any]) -> StepConfig:
140
242
 
141
243
 
142
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)
143
248
  unknown = set(raw) - ALLOWED_TOP_LEVEL_KEYS
144
249
  if unknown:
145
250
  raise HookError(
@@ -156,6 +261,7 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
156
261
 
157
262
  modules: dict[str, ModuleConfig] = {}
158
263
  for module_id in workflow_modules:
264
+ validate_path_component(module_id, "Workflow module id")
159
265
  if module_id not in module_payload:
160
266
  raise HookError(f"workflow.modules references unknown module `{module_id}`")
161
267
  module_raw = module_payload[module_id]
@@ -171,6 +277,14 @@ def _build_config(raw: dict[str, Any]) -> HookConfig:
171
277
  general = GeneralConfig(**raw.get("general", {}))
172
278
  llm = LlmConfig(**raw.get("llm", {}))
173
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/")
174
288
  return HookConfig(
175
289
  general=general,
176
290
  llm=llm,
@@ -187,6 +301,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
187
301
  "allow_push_on_error": config.general.allow_push_on_error,
188
302
  "require_clean_worktree": config.general.require_clean_worktree,
189
303
  "skip_on_sync_branch": config.general.skip_on_sync_branch,
304
+ "base_branch": config.general.base_branch,
190
305
  },
191
306
  "llm": config.llm.__dict__.copy(),
192
307
  "logging": config.logging.__dict__.copy(),
@@ -199,23 +314,37 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
199
314
  "steps": [step.__dict__.copy() for step in module.steps],
200
315
  }
201
316
 
202
- 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")
203
329
  if skip is True:
204
330
  raw["general"]["enabled"] = False
205
- 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")
206
332
  if allow_on_error is not None:
207
333
  raw["general"]["allow_push_on_error"] = allow_on_error
208
- require_clean = env_bool("AI_PUSH_HOOKS_REQUIRE_CLEAN")
334
+ require_clean = read_env_bool("AI_PUSH_HOOKS_REQUIRE_CLEAN")
209
335
  if require_clean is not None:
210
336
  raw["general"]["require_clean_worktree"] = require_clean
211
- allow_dirty = env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
337
+ allow_dirty = read_env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
212
338
  if allow_dirty is True:
213
339
  raw["general"]["require_clean_worktree"] = False
340
+ base_branch = os.getenv("AI_PUSH_HOOKS_BASE_BRANCH")
341
+ if base_branch:
342
+ raw["general"]["base_branch"] = base_branch.strip() or "main"
214
343
 
215
344
  logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
216
345
  if logging_level:
217
346
  raw["logging"]["level"] = logging_level.strip().lower()
218
- print_output = env_bool("AI_PUSH_HOOKS_PRINT_LLM_OUTPUT")
347
+ print_output = read_env_bool("AI_PUSH_HOOKS_PRINT_LLM_OUTPUT")
219
348
  if print_output is not None:
220
349
  raw["logging"]["print_llm_output"] = print_output
221
350
  model = os.getenv("AI_PUSH_HOOKS_MODEL")
@@ -225,8 +354,20 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
225
354
  if variant is not None:
226
355
  raw["llm"]["variant"] = variant.strip()
227
356
  timeout = os.getenv("AI_PUSH_HOOKS_TIMEOUT_SECONDS")
228
- if timeout:
229
- 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
230
371
  return _build_config(raw)
231
372
 
232
373
 
@@ -237,10 +378,21 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
237
378
  "Missing required config file `ai-push-hooks.toml` in repo root. "
238
379
  "Run `ai-push-hooks init --template minimal-docs` first"
239
380
  )
240
- text = config_path.read_text(encoding="utf-8")
241
- loaded = tomllib.loads(text) if tomllib is not None else parse_toml_fallback(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
242
394
  if not isinstance(loaded, dict):
243
- raise HookError(f"Invalid config format in {config_path}")
395
+ raise HookError(f"Invalid config format in {config_path}: expected a top-level table")
244
396
  return _apply_env_overrides(_build_config(loaded)), config_path
245
397
 
246
398
 
@@ -248,11 +400,46 @@ def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
248
400
  if step.prompt and step.prompt.strip():
249
401
  return step.prompt.strip()
250
402
  if step.prompt_file:
251
- prompt_path = pathlib.Path(step.prompt_file)
252
- if not prompt_path.is_absolute():
253
- 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")
254
424
  if prompt_path.exists():
255
- 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)
256
443
  if text:
257
444
  return text
258
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)