ai-push-hooks 0.2.1 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +80 -525
  3. package/SECURITY.md +102 -14
  4. package/ai-push-hooks.toml +9 -2
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/package.json +3 -2
  7. package/pyproject.toml +1 -1
  8. package/src/ai_push_hooks/artifacts.py +67 -13
  9. package/src/ai_push_hooks/config.py +575 -22
  10. package/src/ai_push_hooks/engine.py +116 -7
  11. package/src/ai_push_hooks/executors/apply.py +75 -36
  12. package/src/ai_push_hooks/executors/ask.py +224 -0
  13. package/src/ai_push_hooks/executors/exec.py +17 -801
  14. package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
  15. package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
  16. package/src/ai_push_hooks/executors/runners/claude.py +286 -0
  17. package/src/ai_push_hooks/executors/runners/codex.py +254 -0
  18. package/src/ai_push_hooks/executors/runners/command.py +178 -0
  19. package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
  20. package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
  22. package/src/ai_push_hooks/executors/runners/process.py +464 -0
  23. package/src/ai_push_hooks/executors/runners/registry.py +117 -0
  24. package/src/ai_push_hooks/executors/step_commands.py +478 -0
  25. package/src/ai_push_hooks/git_utils.py +834 -0
  26. package/src/ai_push_hooks/hook.py +1 -1
  27. package/src/ai_push_hooks/modules/beads.py +1 -1
  28. package/src/ai_push_hooks/modules/docs.py +129 -89
  29. package/src/ai_push_hooks/modules/pr.py +1 -1
  30. package/src/ai_push_hooks/plugin_loader.py +422 -0
  31. package/src/ai_push_hooks/plugins.py +134 -0
  32. package/src/ai_push_hooks/prompts_builtin.py +9 -2
  33. package/src/ai_push_hooks/types.py +407 -75
  34. package/vendor/README.md +15 -0
  35. package/vendor/requirements.txt +1 -0
  36. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  37. package/src/ai_push_hooks/executors/llm.py +0 -624
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
- OpenCode is a separate local process and communicates with the model provider selected in `[llm].model`. Diffs, changed-file context, prompts, and step artifacts can therefore leave the machine under that provider's terms. ai-push-hooks retains OpenCode's existing authentication data directory and forwards recognized provider credential environment variables, including `OPENAI_API_KEY`; OpenCode itself chooses authentication using its normal precedence. Built-in authentication plugins remain available, while `--pure` excludes external plugins and the isolated project/config setup excludes project/global plugins, MCP servers, instructions, and custom-provider configuration. Do not commit secrets, and review provider retention and privacy policies before use on sensitive repositories.
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
- Hook logs, summaries, run artifacts, and transcripts are stored locally under `.git/ai-push-hooks/` with private runtime permissions. Transcript capture defaults to **on** at `.git/ai-push-hooks/transcripts`; set `logging.capture_llm_transcript = false` to disable it. OpenCode session deletion defaults to on, but provider-side retention is controlled by the provider.
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,86 @@ 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
- ## Sandbox limitation
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 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`
58
+ values, but those paths do not make file contents read-only. Callback prints and
59
+ direct writes can disclose or modify host data and are outside host
60
+ sanitization. Use a separately managed low-privilege process/container/VM when
61
+ that boundary is required.
62
+
63
+ Command steps use direct argv with no implicit shell, repository-root cwd,
64
+ inherited environment, and EOF on stdin unless an exact declared input is
65
+ selected. `{repo}`, `{python}`, and `{input:<logical-ref>}` are substituted only
66
+ as whole argv elements; unknown tokens in the reserved grammar and embedded
67
+ recognized tokens are rejected, while ordinary brace text is preserved. The
68
+ default command timeout is 60 seconds. stdout/stderr are private, unredacted
69
+ `stdout.txt`/`stderr.txt` artifacts and are not printed by default. Each stream
70
+ is capped at 16 MiB; invalid UTF-8, timeout, signal, missing executable, or
71
+ truncation fails closed. A command may explicitly invoke `bash -c`, and
72
+ `exec`/`assert` commands may modify the real checkout, so these are user-policy
73
+ choices rather than host isolation guarantees. `assert` saves its report before
74
+ blocking on a false/nonzero result. Only the workflow-level fail-open setting
75
+ overrides that block; there is no per-command override.
76
+
77
+ Read-only `collect` callback work may overlap up to `max_parallel`; trusted
78
+ callback/command authors must provide their own concurrency safety. `exec` and
79
+ `assert` remain serialized, and `apply` remains a separate staged, allowlisted
80
+ operation.
81
+
82
+ ## Boundary and apply limitations
83
+
84
+ OpenCode permissions and temporary-workspace isolation are **not an
85
+ operating-system sandbox**. There is no mandatory command allowlist, shell
86
+ parser, container, credential broker, or trust prompt. Custom commands are
87
+ arbitrary user-authorized argv programs and a nominally read-only custom `ask`
88
+ profile is not enforced as read-only. `collect`/`ask` work may overlap up to
89
+ `max_parallel`; trusted custom commands must tolerate that. `apply` is globally
90
+ serialized, but this does not prevent a same-user process from changing the
91
+ host.
92
+
93
+ Project apply uses a point-in-time staging projection. It excludes ignored files
94
+ including tracked-but-ignored files, Git metadata, casefolded/Unicode-normalized
95
+ `AGENTS.md` paths, symlinks/reparse points, and special files. Propagation still
96
+ requires `allow_paths` plus destination and Git-state checks. Runner inputs and
97
+ captured stdout/stderr are each bounded to 16 MiB; staging is bounded to 10,000
98
+ entries/256 MiB and Git metadata snapshots to 20,000 entries/64 MiB. These
99
+ limits are resource and scope controls, not isolation. Existing baseline checks
100
+ are not an atomic CAS against arbitrary external writers, and automatic rollback
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.
22
109
 
23
- OpenCode permissions and temporary-workspace isolation are **not an operating-system sandbox**. The process retains the invoking user's OS-level access, and bounded snapshots cannot observe every ignored path, Git object/LFS store, shared reflog, other linked-worktree metadata, or race with an independent local process. Use an OS sandbox, container, VM, or dedicated low-privilege account when stronger isolation is required. See the README's [OpenCode isolation limits](README.md#opencode-isolation-limits) for the detailed guarantees and exclusions.
110
+ Timeout cleanup has platform limits: POSIX uses a private process group on a
111
+ best-effort basis, while Windows can terminate only the direct child. Neither
112
+ is a sandbox; Windows has no native beta evidence.
24
113
 
25
- ## Tested security boundary
114
+ ## Historical 0.3.0 security evidence
26
115
 
27
- The beta evidence covers the real OpenCode **1.18.29** permission contract in a
28
- no-network Docker fixture and a limited synthetic provider run using a model
29
- that was listed as free at test time,
30
- `opencode/muse-spark-1.3-contributor-free`. Free-model catalogs and pricing can
31
- change; verify the current catalog before use. This evidence does not cover
32
- every provider, model, authentication mode, or live `apply` path. Windows has
33
- no native beta evidence. Treat the generated hook's repository-local path
34
- checks and the Lefthook runner as integration safeguards, not isolation
35
- boundaries.
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,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 = "llm"
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 = "llm"
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.
@@ -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.2.1",
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.2.1"
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"
@@ -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:
@@ -127,16 +194,3 @@ class ArtifactStore:
127
194
  )
128
195
  raise HookError(f"Unknown artifact reference: {reference}")
129
196
  return path
130
-
131
- def register_external(
132
- self,
133
- state: ModuleRuntimeState,
134
- module_id: str,
135
- step_id: str,
136
- artifact_name: str,
137
- path: pathlib.Path,
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")
142
- state.artifacts[f"{module_id}:{step_id}/{artifact_name}"] = path