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/CHANGELOG.md +30 -2
- package/README.md +72 -996
- package/SECURITY.md +21 -16
- package/bin/ai-push-hooks.js +6 -6
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +0 -13
- package/src/ai_push_hooks/config.py +19 -5
- package/src/ai_push_hooks/engine.py +2 -1
- package/src/ai_push_hooks/executors/apply.py +3 -3
- package/src/ai_push_hooks/executors/ask.py +0 -523
- package/src/ai_push_hooks/executors/exec.py +17 -801
- package/src/ai_push_hooks/executors/runner_workflow.py +4 -4
- package/src/ai_push_hooks/executors/runners/opencode.py +40 -11
- package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
- package/src/ai_push_hooks/executors/runners/process.py +72 -11
- package/src/ai_push_hooks/executors/runners/registry.py +17 -4
- package/src/ai_push_hooks/executors/step_commands.py +7 -2
- package/src/ai_push_hooks/git_utils.py +834 -0
- package/src/ai_push_hooks/hook.py +1 -1
- package/src/ai_push_hooks/modules/beads.py +1 -1
- package/src/ai_push_hooks/modules/docs.py +129 -89
- package/src/ai_push_hooks/modules/pr.py +1 -1
- package/src/ai_push_hooks/plugin_loader.py +106 -82
- package/src/ai_push_hooks/types.py +1 -0
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
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
|
|
56
|
-
timeout
|
|
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
|
|
101
|
-
|
|
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
|
-
##
|
|
108
|
-
|
|
109
|
-
The
|
|
110
|
-
|
|
111
|
-
in-process loopback mock provider and no external
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
package/bin/ai-push-hooks.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 .
|
|
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
|
-
|
|
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,
|
|
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=
|
|
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
|
|
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
|
|
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 =
|
|
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"))
|