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.
package/pyproject.toml CHANGED
@@ -4,23 +4,31 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-push-hooks"
7
- version = "0.1.18"
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
  ]
25
+ dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
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"
24
32
 
25
33
  [project.scripts]
26
34
  ai-push-hooks = "ai_push_hooks.cli:main"
@@ -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,14 +102,17 @@ 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:
65
110
  if ":" in reference:
66
- module_and_step, artifact_name = reference.split("/", 1)
67
- module_id, step_id = module_and_step.split(":", 1)
111
+ try:
112
+ module_and_step, artifact_name = reference.split("/", 1)
113
+ module_id, step_id = module_and_step.split(":", 1)
114
+ except ValueError as exc:
115
+ raise HookError(f"Invalid artifact reference: {reference}") from exc
68
116
  key = f"{module_id}:{step_id}/{artifact_name}"
69
117
  else:
70
118
  key = reference
@@ -72,6 +120,11 @@ class ArtifactStore:
72
120
  if path is None:
73
121
  path = state.artifacts.get(reference)
74
122
  if path is None:
123
+ if ":" in reference:
124
+ raise HookError(
125
+ f"Unknown artifact reference: {reference}. Artifact references are module-local; "
126
+ "use '<step>/<artifact>' from an earlier step in the same module."
127
+ )
75
128
  raise HookError(f"Unknown artifact reference: {reference}")
76
129
  return path
77
130
 
@@ -83,4 +136,7 @@ class ArtifactStore:
83
136
  artifact_name: str,
84
137
  path: pathlib.Path,
85
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")
86
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")