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/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 forwards recognized provider credential environment variables and retains OpenCode's existing authentication data directory, but disables sharing and does not inherit project/global plugins, MCP servers, instructions, or 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,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
- ## 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 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
- 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.
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 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.
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.
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-push-hooks",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run structured AI-assisted checks and allowlisted maintenance before git push",
5
5
  "license": "MIT",
6
6
  "author": {
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.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: