ai-push-hooks 0.3.0 → 0.3.1

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/SECURITY.md CHANGED
@@ -52,8 +52,9 @@ or the environment. A single-file callback may import dependencies already
52
52
  installed in the interpreter running the hook, but the host never runs `pip`;
53
53
  sibling/package-relative imports and installed-module references are not a
54
54
  supported loading mechanism. The callback runs in-process as trusted user code:
55
- there is no SDK, sandbox, filesystem write prevention, or enforceable hard
56
- timeout. Its `PluginContext` has frozen mappings/snapshots and validated `Path`
55
+ there is no SDK, sandbox, filesystem write prevention, or in-process timeout.
56
+ The configured timeout applies to child runner processes, not callback code.
57
+ Its `PluginContext` has frozen mappings/snapshots and validated `Path`
57
58
  values, but those paths do not make file contents read-only. Callback prints and
58
59
  direct writes can disclose or modify host data and are outside host
59
60
  sanitization. Use a separately managed low-privilege process/container/VM when
@@ -97,22 +98,26 @@ captured stdout/stderr are each bounded to 16 MiB; staging is bounded to 10,000
97
98
  entries/256 MiB and Git metadata snapshots to 20,000 entries/64 MiB. These
98
99
  limits are resource and scope controls, not isolation. Existing baseline checks
99
100
  are not an atomic CAS against arbitrary external writers, and automatic rollback
100
- is avoided to protect pre-existing user changes. See the README's [runner
101
- profiles and access modes](README.md#runner-profiles-and-access-modes).
101
+ is avoided to protect pre-existing user changes. See [runner profiles and
102
+ access modes](docs/configuration.md#runner-profiles).
103
+
104
+ Apply intentionally repeats integrity and state scans: it snapshots the checkout
105
+ and Git metadata, inventories staging before and after the runner, checks each
106
+ propagation operation against its baseline, and verifies the propagated result
107
+ and protected state afterward. These repeated checks are defense in depth, not
108
+ an atomic CAS or an automatic rollback.
102
109
 
103
110
  Timeout cleanup has platform limits: POSIX uses a private process group on a
104
111
  best-effort basis, while Windows can terminate only the direct child. Neither
105
112
  is a sandbox; Windows has no native beta evidence.
106
113
 
107
- ## Tested security boundary
108
-
109
- The final pinned-Lefthook suite passed **407 tests with no skips**. The current
110
- evidence also includes the real OpenCode **1.18.29** contract smoke test with an
111
- in-process loopback mock provider and no external model call; its read probe
112
- checks that no Git-visible project files were mutated. It does not cover every
113
- provider, model, authentication mode, or live `apply` path.
114
- Installed Codex **0.148.0** and Claude **2.1.220** checks use only version/help
115
- output. Live Codex/Pi verification was intentionally not run pending separate
116
- approval; Claude live verification is pending because no subscription is
117
- available. Treat generated-hook path checks and the Lefthook runner as
118
- integration safeguards, not isolation boundaries.
114
+ ## Historical 0.3.0 security evidence
115
+
116
+ The [0.3.0 release record](CHANGELOG.md#030---2026-09-09) documented a pinned
117
+ Lefthook suite reporting **407 tests with no skips**, an OpenCode **1.18.29**
118
+ contract smoke test with an in-process loopback mock provider and no external
119
+ model call, and version/help-only checks for Codex **0.148.0** and Claude
120
+ **2.1.220**. This is historical evidence, not a current suite result or proof
121
+ for every provider, model, authentication mode, platform, or live `apply` path.
122
+ Treat generated-hook path checks and the Lefthook runner as integration
123
+ safeguards, not isolation boundaries.
@@ -6,16 +6,16 @@ const path = require('node:path');
6
6
 
7
7
  const packageRoot = path.resolve(__dirname, '..');
8
8
  const srcDir = path.join(packageRoot, 'src');
9
+ const tomliWheel = path.join(packageRoot, 'vendor', 'tomli-2.4.0-py3-none-any.whl');
9
10
  const args = ['-m', 'ai_push_hooks', ...process.argv.slice(2)];
10
- const pythonCommands = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3', 'python'];
11
+ const pythonCommands = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3', 'python'];
11
12
 
12
13
  function buildEnv() {
13
14
  const env = { ...process.env };
14
15
  env.AI_PUSH_HOOKS_NODE_EXECUTABLE = process.execPath;
15
16
  env.AI_PUSH_HOOKS_NODE_SCRIPT = fs.realpathSync(__filename);
16
- env.PYTHONPATH = env.PYTHONPATH
17
- ? `${srcDir}${path.delimiter}${env.PYTHONPATH}`
18
- : srcDir;
17
+ // Pure-Python wheels are importable archives; no pip or install scripts needed.
18
+ env.PYTHONPATH = [srcDir, tomliWheel, env.PYTHONPATH].filter(Boolean).join(path.delimiter);
19
19
  return env;
20
20
  }
21
21
 
@@ -33,7 +33,7 @@ function canRunPackage(command) {
33
33
  '-c',
34
34
  'import sys; assert sys.version_info >= (3, 10); __import__("tomllib" if sys.version_info >= (3, 11) else "tomli")',
35
35
  ],
36
- { stdio: 'ignore' },
36
+ { stdio: 'ignore', env: buildEnv() },
37
37
  );
38
38
  return check.status === 0;
39
39
  }
@@ -41,7 +41,7 @@ function canRunPackage(command) {
41
41
  const pythonCommand = pythonCommands.find(canRunPackage);
42
42
  if (!pythonCommand) {
43
43
  console.error(
44
- '[ai-push-hooks] Python 3.11+ is required for npm installs. Python 3.10 can be used if tomli is installed.',
44
+ '[ai-push-hooks] Python 3.10+ is required and must be available on PATH.',
45
45
  );
46
46
  process.exit(1);
47
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-push-hooks",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Run structured AI-assisted checks and allowlisted maintenance before git push",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -30,12 +30,13 @@
30
30
  "ai-push-hooks": "bin/ai-push-hooks.js"
31
31
  },
32
32
  "scripts": {
33
- "test": "uv run --no-project --with pytest pytest tests -q",
33
+ "test": "uv run --no-project --with pytest --with build --with pip --with \"tomli; python_version < '3.11'\" pytest tests -q",
34
34
  "test:npm-pack": "node tests/npm-pack-smoke.mjs"
35
35
  },
36
36
  "files": [
37
37
  "bin",
38
38
  "src/**/*.py",
39
+ "vendor",
39
40
  "README.md",
40
41
  "CHANGELOG.md",
41
42
  "SECURITY.md",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-push-hooks"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Run structured AI-assisted checks and allowlisted maintenance before git push"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -194,16 +194,3 @@ class ArtifactStore:
194
194
  )
195
195
  raise HookError(f"Unknown artifact reference: {reference}")
196
196
  return path
197
-
198
- def register_external(
199
- self,
200
- state: ModuleRuntimeState,
201
- module_id: str,
202
- step_id: str,
203
- artifact_name: str,
204
- path: pathlib.Path,
205
- ) -> None:
206
- validate_path_component(module_id, "Artifact module id")
207
- validate_path_component(step_id, "Artifact step id")
208
- validate_path_component(artifact_name, "Artifact name")
209
- state.artifacts[f"{module_id}:{step_id}/{artifact_name}"] = path
@@ -8,7 +8,7 @@ import stat
8
8
  from collections.abc import Mapping
9
9
  from typing import Any
10
10
 
11
- from .executors.exec import env_bool, resolve_git_common_dir, resolve_git_dir
11
+ from .git_utils import env_bool, resolve_git_common_dir, resolve_git_dir
12
12
  from .paths import (
13
13
  is_path_within,
14
14
  normalized_component,
@@ -19,6 +19,7 @@ from .paths import (
19
19
  )
20
20
  from .prompts_builtin import BUILTIN_PROMPTS
21
21
  from .types import (
22
+ DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
22
23
  SUPPORTED_STEP_TYPES,
23
24
  GeneralConfig,
24
25
  HookConfig,
@@ -106,7 +107,6 @@ EMBEDDED_COMMAND_PLACEHOLDER_PATTERN = re.compile(
106
107
  r"\{(?:repo|python|input:[A-Za-z0-9_./:-]+)\}"
107
108
  )
108
109
  COMMAND_PLACEHOLDER_NAMES = frozenset({"repo", "python"})
109
- DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
110
110
  RUNNER_PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
111
111
 
112
112
 
@@ -683,7 +683,9 @@ def _build_config(
683
683
  _normalize_step(
684
684
  step,
685
685
  f"modules.{module_id}.steps[{index}]",
686
- repo_root=repo_root,
686
+ # Repository filesystem checks are performed once below,
687
+ # after environment overrides have been resolved.
688
+ repo_root=None,
687
689
  )
688
690
  for index, step in enumerate(steps_raw, start=1)
689
691
  ),
@@ -693,10 +695,14 @@ def _build_config(
693
695
  # while preserving the existing runtime model that only workflow modules are
694
696
  # materialized in HookConfig.modules.
695
697
  if repo_root is not None:
698
+ validated_python_references: set[str] = set()
696
699
  for module_id, module_raw in module_payload.items():
697
700
  for index, step_raw in enumerate(module_raw.get("steps", []) or [], start=1):
698
701
  python_ref = step_raw.get("python")
699
702
  if python_ref is not None:
703
+ if python_ref in validated_python_references:
704
+ continue
705
+ validated_python_references.add(python_ref)
700
706
  _validate_python_reference(
701
707
  python_ref,
702
708
  f"modules.{module_id}.steps[{index}].python",
@@ -789,7 +795,10 @@ def resolve_runner_profile(
789
795
 
790
796
 
791
797
  def _apply_env_overrides(
792
- config: HookConfig, *, repo_root: pathlib.Path | None = None
798
+ config: HookConfig,
799
+ *,
800
+ repo_root: pathlib.Path | None = None,
801
+ raw_modules: dict[str, Any] | None = None,
793
802
  ) -> HookConfig:
794
803
  raw = {
795
804
  "general": {
@@ -830,6 +839,10 @@ def _apply_env_overrides(
830
839
  "enabled": module.enabled,
831
840
  "steps": step_payloads,
832
841
  }
842
+ if raw_modules is not None:
843
+ # Keep disabled and unselected modules in the final build so their
844
+ # callback references are validated without materializing them.
845
+ raw["modules"] = raw_modules
833
846
  for name, profile in config.runners.items():
834
847
  runner_raw: dict[str, Any] = {
835
848
  "type": profile.type,
@@ -930,8 +943,9 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
930
943
  _validate_model_override(model_override)
931
944
  _validate_variant_override(variant_override)
932
945
  return _apply_env_overrides(
933
- _build_config(loaded, effective_model=model_override, repo_root=repo_root),
946
+ _build_config(loaded, effective_model=model_override, repo_root=None),
934
947
  repo_root=repo_root,
948
+ raw_modules=loaded.get("modules", {}),
935
949
  ), config_path
936
950
 
937
951
 
@@ -8,7 +8,8 @@ from .artifacts import ArtifactStore
8
8
  from .config import resolve_prompt_text
9
9
  from .executors.apply import run_apply_step
10
10
  from .executors.assertions import ASSERTION_HANDLERS
11
- from .executors.exec import EXEC_HANDLERS, env_bool
11
+ from .executors.exec import EXEC_HANDLERS
12
+ from .git_utils import env_bool
12
13
  from .executors.ask import run_ask_step
13
14
  from .executors.step_commands import execute_step_command
14
15
  from .modules import COLLECTORS
@@ -21,15 +21,15 @@ from ..paths import (
21
21
  sanitize_file_mode,
22
22
  )
23
23
  from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
24
- from .exec import (
24
+ from ..git_utils import (
25
25
  list_repo_changes,
26
26
  path_matches,
27
27
  resolve_git_common_dir,
28
28
  resolve_git_dir,
29
29
  run_command,
30
30
  )
31
- from .ask import validate_opencode_attachments
32
31
  from .runner_workflow import run_runner_once
32
+ from .runners.opencode_support import validate_hook_owned_artifacts
33
33
 
34
34
  METADATA_MAX_FILES = 20_000
35
35
  METADATA_MAX_BYTES = 64 * 1024 * 1024
@@ -815,7 +815,7 @@ def run_apply_step(
815
815
  input_paths: list[pathlib.Path],
816
816
  stage_name: str,
817
817
  ) -> dict[str, object]:
818
- validated_inputs = validate_opencode_attachments(context, input_paths)
818
+ validated_inputs = validate_hook_owned_artifacts(context, input_paths)
819
819
  for input_path in validated_inputs:
820
820
  if input_path.name.endswith("issues.json"):
821
821
  issues = json.loads(input_path.read_text(encoding="utf-8"))