ai-push-hooks 0.2.0 → 0.3.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/CHANGELOG.md +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
package/SECURITY.md
CHANGED
|
@@ -8,9 +8,29 @@ Please do not open a public issue for a suspected vulnerability. Report it priva
|
|
|
8
8
|
|
|
9
9
|
ai-push-hooks treats repository content, Git paths and metadata, configuration, model output, and concurrent local filesystem changes as potentially unsafe. Its controls constrain model-visible inputs and apply destinations, protect Git metadata and instruction files, validate filesystem state before propagation, and fail closed by default. They are designed to prevent accidental or model-directed changes outside configured boundaries, not to protect against a malicious user or process with the same operating-system permissions.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Every selected runner is a separate local process. Diffs, changed-file context,
|
|
12
|
+
prompts, artifacts, and (in project mode) more repository content can therefore
|
|
13
|
+
leave the machine under the selected provider's terms. OpenCode retains its
|
|
14
|
+
existing authentication data directory and forwards recognized provider
|
|
15
|
+
environment variables, including `OPENAI_API_KEY`; OpenCode itself chooses the
|
|
16
|
+
authentication path. Codex, Claude, and custom commands inherit the user's
|
|
17
|
+
normal environment/home needed by their tooling. Authentication is user-owned:
|
|
18
|
+
ai-push-hooks does not log environment values, manage credentials, invoke login,
|
|
19
|
+
or provide a credential broker. Do not commit secrets, and review provider
|
|
20
|
+
retention, privacy, and billing policies before use on sensitive repositories.
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
OpenCode's `--pure` and isolated configuration exclude external plugins,
|
|
23
|
+
project/global configuration, MCP servers, instructions, and global custom
|
|
24
|
+
providers while retaining built-in plugins such as Codex OAuth. The default
|
|
25
|
+
OpenCode profile remains artifact-only; project access is an explicit opt-in.
|
|
26
|
+
|
|
27
|
+
Hook logs, summaries, run artifacts, and OpenCode transcripts are stored locally
|
|
28
|
+
under `.git/ai-push-hooks/` with private runtime permissions. Transcript capture
|
|
29
|
+
defaults to **on** at `.git/ai-push-hooks/transcripts`; set
|
|
30
|
+
`logging.capture_llm_transcript = false` to disable it. OpenCode session deletion
|
|
31
|
+
defaults to on, but provider-side retention is controlled by the provider.
|
|
32
|
+
Codex and Claude are ephemeral/no-persistence by default, and command profiles
|
|
33
|
+
have no inferred transcript lifecycle.
|
|
14
34
|
|
|
15
35
|
Transcript export is best effort. If export fails or produces no usable output,
|
|
16
36
|
the run emits a warning and still applies the configured session-deletion
|
|
@@ -18,18 +38,81 @@ policy; it does not claim that a transcript was captured. A provider may have
|
|
|
18
38
|
already received the request even when local export fails. Do not use local
|
|
19
39
|
transcript files as proof that provider-side data was deleted.
|
|
20
40
|
|
|
21
|
-
##
|
|
41
|
+
## Repository callbacks and commands
|
|
42
|
+
|
|
43
|
+
The published `0.3.0` beta includes the `ask` spelling, repository Python
|
|
44
|
+
callbacks, and direct `exec`/`assert` commands. The previous `0.2.1` beta used
|
|
45
|
+
`llm` for model-backed workflow steps; there is no compatibility alias, so
|
|
46
|
+
configurations must be updated when upgrading.
|
|
47
|
+
|
|
48
|
+
A callback reference is one contained, no-follow regular `.py` file plus one
|
|
49
|
+
top-level callable. It is loaded lazily only after module, environment, and
|
|
50
|
+
input gates, and cached once per run. Loading does not mutate `sys.path`, cwd,
|
|
51
|
+
or the environment. A single-file callback may import dependencies already
|
|
52
|
+
installed in the interpreter running the hook, but the host never runs `pip`;
|
|
53
|
+
sibling/package-relative imports and installed-module references are not a
|
|
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`
|
|
57
|
+
values, but those paths do not make file contents read-only. Callback prints and
|
|
58
|
+
direct writes can disclose or modify host data and are outside host
|
|
59
|
+
sanitization. Use a separately managed low-privilege process/container/VM when
|
|
60
|
+
that boundary is required.
|
|
61
|
+
|
|
62
|
+
Command steps use direct argv with no implicit shell, repository-root cwd,
|
|
63
|
+
inherited environment, and EOF on stdin unless an exact declared input is
|
|
64
|
+
selected. `{repo}`, `{python}`, and `{input:<logical-ref>}` are substituted only
|
|
65
|
+
as whole argv elements; unknown tokens in the reserved grammar and embedded
|
|
66
|
+
recognized tokens are rejected, while ordinary brace text is preserved. The
|
|
67
|
+
default command timeout is 60 seconds. stdout/stderr are private, unredacted
|
|
68
|
+
`stdout.txt`/`stderr.txt` artifacts and are not printed by default. Each stream
|
|
69
|
+
is capped at 16 MiB; invalid UTF-8, timeout, signal, missing executable, or
|
|
70
|
+
truncation fails closed. A command may explicitly invoke `bash -c`, and
|
|
71
|
+
`exec`/`assert` commands may modify the real checkout, so these are user-policy
|
|
72
|
+
choices rather than host isolation guarantees. `assert` saves its report before
|
|
73
|
+
blocking on a false/nonzero result. Only the workflow-level fail-open setting
|
|
74
|
+
overrides that block; there is no per-command override.
|
|
75
|
+
|
|
76
|
+
Read-only `collect` callback work may overlap up to `max_parallel`; trusted
|
|
77
|
+
callback/command authors must provide their own concurrency safety. `exec` and
|
|
78
|
+
`assert` remain serialized, and `apply` remains a separate staged, allowlisted
|
|
79
|
+
operation.
|
|
80
|
+
|
|
81
|
+
## Boundary and apply limitations
|
|
82
|
+
|
|
83
|
+
OpenCode permissions and temporary-workspace isolation are **not an
|
|
84
|
+
operating-system sandbox**. There is no mandatory command allowlist, shell
|
|
85
|
+
parser, container, credential broker, or trust prompt. Custom commands are
|
|
86
|
+
arbitrary user-authorized argv programs and a nominally read-only custom `ask`
|
|
87
|
+
profile is not enforced as read-only. `collect`/`ask` work may overlap up to
|
|
88
|
+
`max_parallel`; trusted custom commands must tolerate that. `apply` is globally
|
|
89
|
+
serialized, but this does not prevent a same-user process from changing the
|
|
90
|
+
host.
|
|
91
|
+
|
|
92
|
+
Project apply uses a point-in-time staging projection. It excludes ignored files
|
|
93
|
+
including tracked-but-ignored files, Git metadata, casefolded/Unicode-normalized
|
|
94
|
+
`AGENTS.md` paths, symlinks/reparse points, and special files. Propagation still
|
|
95
|
+
requires `allow_paths` plus destination and Git-state checks. Runner inputs and
|
|
96
|
+
captured stdout/stderr are each bounded to 16 MiB; staging is bounded to 10,000
|
|
97
|
+
entries/256 MiB and Git metadata snapshots to 20,000 entries/64 MiB. These
|
|
98
|
+
limits are resource and scope controls, not isolation. Existing baseline checks
|
|
99
|
+
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).
|
|
22
102
|
|
|
23
|
-
|
|
103
|
+
Timeout cleanup has platform limits: POSIX uses a private process group on a
|
|
104
|
+
best-effort basis, while Windows can terminate only the direct child. Neither
|
|
105
|
+
is a sandbox; Windows has no native beta evidence.
|
|
24
106
|
|
|
25
107
|
## Tested security boundary
|
|
26
108
|
|
|
27
|
-
The
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
package/ai-push-hooks.toml
CHANGED
|
@@ -6,6 +6,11 @@ skip_on_sync_branch = true
|
|
|
6
6
|
base_branch = "main"
|
|
7
7
|
|
|
8
8
|
[llm]
|
|
9
|
+
# Compatibility default: OpenCode receives validated hook artifacts only.
|
|
10
|
+
# Define [runners.<name>] and select it with [llm].runner, or override an
|
|
11
|
+
# individual ask/apply step with runner = "<name>". Project access is explicit.
|
|
12
|
+
# Repository callbacks use python = "path/to/file.py:callable" and command
|
|
13
|
+
# steps use a direct argv array; both are trusted local code.
|
|
9
14
|
runner = "opencode"
|
|
10
15
|
model = "openai/gpt-5.6-terra"
|
|
11
16
|
variant = ""
|
|
@@ -17,6 +22,8 @@ json_retry_new_session = true
|
|
|
17
22
|
delete_session_after_run = true
|
|
18
23
|
|
|
19
24
|
[logging]
|
|
25
|
+
# Transcripts are OpenCode-only lifecycle exports; other runners are ephemeral
|
|
26
|
+
# or command-owned. print_llm_output, when enabled, is normalized/redacted text.
|
|
20
27
|
level = "status"
|
|
21
28
|
jsonl = true
|
|
22
29
|
dir = ".git/ai-push-hooks/logs"
|
|
@@ -37,7 +44,7 @@ collector = "docs_context"
|
|
|
37
44
|
|
|
38
45
|
[[modules.docs.steps]]
|
|
39
46
|
id = "query"
|
|
40
|
-
type = "
|
|
47
|
+
type = "ask"
|
|
41
48
|
prompt = """
|
|
42
49
|
Given the attached diff and changed file list, output a JSON array of concise
|
|
43
50
|
documentation search queries. Return JSON only.
|
|
@@ -48,7 +55,7 @@ schema = "string_array"
|
|
|
48
55
|
|
|
49
56
|
[[modules.docs.steps]]
|
|
50
57
|
id = "analyze"
|
|
51
|
-
type = "
|
|
58
|
+
type = "ask"
|
|
52
59
|
prompt = """
|
|
53
60
|
Review the diff and matched docs excerpts. Return JSON issues only for factual
|
|
54
61
|
documentation drift caused by the code changes.
|
package/package.json
CHANGED
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.
|
|
7
|
+
version = "0.3.0"
|
|
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"
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import pathlib
|
|
5
|
+
from collections.abc import Mapping
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
from typing import Any
|
|
7
8
|
from uuid import uuid4
|
|
@@ -13,11 +14,16 @@ from .paths import (
|
|
|
13
14
|
path_is_link_or_reparse,
|
|
14
15
|
resolve_contained_path,
|
|
15
16
|
validate_path_component,
|
|
17
|
+
atomic_write_bytes,
|
|
16
18
|
write_text_no_follow,
|
|
17
19
|
)
|
|
18
20
|
from .types import HookError, ModuleRuntimeState
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
PLUGIN_ARTIFACT_MAX_BYTES = 16 * 1024 * 1024
|
|
24
|
+
PLUGIN_COLLECT_ARTIFACTS_MAX_BYTES = 64 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
21
27
|
def generate_run_id() -> str:
|
|
22
28
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
23
29
|
return f"{timestamp}-{uuid4().hex[:8]}"
|
|
@@ -94,6 +100,22 @@ class ArtifactStore:
|
|
|
94
100
|
write_text_no_follow(path, content)
|
|
95
101
|
return self.register(state, step_id, artifact_name, path)
|
|
96
102
|
|
|
103
|
+
def write_bytes(
|
|
104
|
+
self,
|
|
105
|
+
state: ModuleRuntimeState,
|
|
106
|
+
step_index: int,
|
|
107
|
+
step_id: str,
|
|
108
|
+
artifact_name: str,
|
|
109
|
+
content: bytes,
|
|
110
|
+
) -> pathlib.Path:
|
|
111
|
+
"""Write an exact, private byte artifact and register it."""
|
|
112
|
+
|
|
113
|
+
if not isinstance(content, bytes):
|
|
114
|
+
raise TypeError("Artifact byte content must be bytes")
|
|
115
|
+
path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
|
|
116
|
+
atomic_write_bytes(path, content)
|
|
117
|
+
return self.register(state, step_id, artifact_name, path)
|
|
118
|
+
|
|
97
119
|
def write_json(
|
|
98
120
|
self,
|
|
99
121
|
state: ModuleRuntimeState,
|
|
@@ -106,6 +128,51 @@ class ArtifactStore:
|
|
|
106
128
|
write_text_no_follow(path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n")
|
|
107
129
|
return self.register(state, step_id, artifact_name, path)
|
|
108
130
|
|
|
131
|
+
def serialize_plugin_artifacts(
|
|
132
|
+
self,
|
|
133
|
+
artifacts: Mapping[str, str | dict[str, Any] | list[Any]],
|
|
134
|
+
*,
|
|
135
|
+
max_artifact_bytes: int = PLUGIN_ARTIFACT_MAX_BYTES,
|
|
136
|
+
max_total_bytes: int = PLUGIN_COLLECT_ARTIFACTS_MAX_BYTES,
|
|
137
|
+
) -> dict[str, bytes]:
|
|
138
|
+
"""Serialize callback artifacts before any output is written.
|
|
139
|
+
|
|
140
|
+
Plugin results are intentionally serialized in the same format as the
|
|
141
|
+
existing collector path. Doing the complete serialization and budget
|
|
142
|
+
check up front prevents a later oversized artifact from leaving an
|
|
143
|
+
earlier callback artifact registered in the run.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
if max_artifact_bytes < 0 or max_total_bytes < 0:
|
|
147
|
+
raise HookError("Plugin artifact budgets must not be negative")
|
|
148
|
+
serialized: dict[str, bytes] = {}
|
|
149
|
+
total_bytes = 0
|
|
150
|
+
for artifact_name, payload in artifacts.items():
|
|
151
|
+
validate_path_component(artifact_name, "CollectorResult artifact name")
|
|
152
|
+
try:
|
|
153
|
+
if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
|
|
154
|
+
content = (
|
|
155
|
+
json.dumps(payload, ensure_ascii=True, indent=2, allow_nan=False) + "\n"
|
|
156
|
+
).encode("utf-8")
|
|
157
|
+
elif isinstance(payload, str):
|
|
158
|
+
content = payload.encode("utf-8")
|
|
159
|
+
else:
|
|
160
|
+
raise TypeError
|
|
161
|
+
except (RecursionError, TypeError, ValueError, UnicodeError):
|
|
162
|
+
raise HookError(
|
|
163
|
+
f"CollectorResult artifact {artifact_name!r} could not be serialized"
|
|
164
|
+
) from None
|
|
165
|
+
|
|
166
|
+
if len(content) > max_artifact_bytes:
|
|
167
|
+
raise HookError(
|
|
168
|
+
f"CollectorResult artifact {artifact_name!r} exceeds the per-artifact size limit"
|
|
169
|
+
)
|
|
170
|
+
total_bytes += len(content)
|
|
171
|
+
if total_bytes > max_total_bytes:
|
|
172
|
+
raise HookError("CollectorResult artifacts exceed the aggregate size limit")
|
|
173
|
+
serialized[artifact_name] = content
|
|
174
|
+
return serialized
|
|
175
|
+
|
|
109
176
|
def resolve_input(self, state: ModuleRuntimeState, reference: str) -> pathlib.Path:
|
|
110
177
|
if ":" in reference:
|
|
111
178
|
try:
|