ai-push-hooks 0.3.2 → 0.3.4
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 +54 -3
- package/README.md +11 -14
- package/SECURITY.md +20 -5
- package/bin/ai-push-hooks.js +13 -11
- package/bin/bootstrap.py +69 -0
- package/docs/configuration.md +83 -5
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/ai_push_hooks/cli.py +2 -1
- package/src/ai_push_hooks/executors/apply.py +27 -10
- package/src/ai_push_hooks/executors/ask.py +10 -4
- package/src/ai_push_hooks/executors/assertions.py +5 -2
- package/src/ai_push_hooks/executors/exec.py +5 -7
- package/src/ai_push_hooks/executors/runners/contracts.py +75 -17
- package/src/ai_push_hooks/executors/step_commands.py +101 -63
- package/src/ai_push_hooks/git_utils.py +13 -2
- package/src/ai_push_hooks/hook.py +5 -2
- package/src/ai_push_hooks/modules/docs.py +33 -5
- package/src/ai_push_hooks/paths.py +19 -7
- package/src/ai_push_hooks/plugin_loader.py +18 -5
- package/vendor/README.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.4] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
This is the first stable release. Python and npm both use `0.3.4`, the canonical
|
|
10
|
+
Git tag is `v0.3.4`, npm uses the `latest` dist-tag, and GitHub publishes a
|
|
11
|
+
standard release.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Failed the clean-worktree gate when Git cannot verify checkout status.
|
|
16
|
+
- Rejected apply destinations inside configured Git hooks directories before
|
|
17
|
+
propagating any changes to the checkout.
|
|
18
|
+
- Isolated npm's Python startup from consuming-repository modules, inherited
|
|
19
|
+
`PYTHONPATH`, and user-site imports so they cannot replace the installed hook.
|
|
20
|
+
- Excluded Git-ignored local documentation from default model context collection.
|
|
21
|
+
- Required explicit boolean Beads verdicts before commands, report changes, or
|
|
22
|
+
quality-gate decisions.
|
|
23
|
+
- Kept failed command output in private artifacts rather than terminal errors.
|
|
24
|
+
- Redacted known sensitive values across diagnostic truncation and partial-output
|
|
25
|
+
boundaries, including timeout errors.
|
|
26
|
+
- Collected review diffs independently of external diff drivers, text conversion,
|
|
27
|
+
and color settings.
|
|
28
|
+
- Made private runtime directory creation tolerate concurrent OpenCode stages
|
|
29
|
+
while retaining directory and symlink checks.
|
|
30
|
+
- Included regular-file, symlink, and other Git type changes in pushed-file
|
|
31
|
+
facts exposed to workflows and Python callbacks.
|
|
32
|
+
- Preserved each Python callback source's annotation semantics and registered
|
|
33
|
+
callback modules for dataclasses and runtime type-hint introspection.
|
|
34
|
+
- Validated recovered Actions artifacts against their associated push run,
|
|
35
|
+
release workflow, repository, tag, and commit before downloading archives.
|
|
36
|
+
|
|
37
|
+
## [0.3.3] - 2026-09-14
|
|
38
|
+
|
|
39
|
+
This is a beta patch release. Python and npm both use `0.3.3`, the canonical
|
|
40
|
+
Git tag is `v0.3.3`, npm uses the `beta` dist-tag, and GitHub marks the release
|
|
41
|
+
as a prerelease. PyPI does not provide a separate beta channel, so Python users
|
|
42
|
+
must select the exact `0.3.3` version.
|
|
43
|
+
|
|
44
|
+
This is a documentation and validation patch only; it adds no runtime features.
|
|
45
|
+
|
|
46
|
+
### Documentation
|
|
47
|
+
|
|
48
|
+
- Added a tested deterministic postcondition recipe that distinguishes successful
|
|
49
|
+
runner execution from a verified fix, while preserving legitimate no-op applies.
|
|
50
|
+
- Recorded live runner validation results for OpenCode, Claude Code, and Codex.
|
|
51
|
+
- Clarified the focused pre-push use case and how the tool complements hook
|
|
52
|
+
managers, background reviewers, and broader agent workflow frameworks.
|
|
53
|
+
- Documented custom-agent CLI support through the custom command runner.
|
|
54
|
+
- Updated the Codex conformance check to 0.152.0 and verified live project reads
|
|
55
|
+
and allowlisted edits with `gpt-5.6-luna`.
|
|
56
|
+
|
|
7
57
|
## [0.3.2] - 2026-09-12
|
|
8
58
|
|
|
9
59
|
This is a beta patch release. Python and npm both use `0.3.2`, the canonical
|
|
@@ -44,8 +94,7 @@ must select the exact `0.3.2` version.
|
|
|
44
94
|
|
|
45
95
|
### Documentation
|
|
46
96
|
|
|
47
|
-
- Added an agent setup skill
|
|
48
|
-
deterministic review/fail/fix/commit/pass demo, explicitly not live AI footage.
|
|
97
|
+
- Added an agent setup skill and CI/npm beta badges.
|
|
49
98
|
- Explained the manual-commit assertion and why applied edits are not part of
|
|
50
99
|
the commit already being pushed.
|
|
51
100
|
- Included the configuration guide in the npm package and removed its link to
|
|
@@ -196,7 +245,9 @@ the exact `0.2.0` version rather than a PyPI beta channel.
|
|
|
196
245
|
- Preserved Git porcelain paths and protected pre-existing dirty allowlisted files during apply steps.
|
|
197
246
|
- Clarified module-local artifact references and standardized the repository hook integration.
|
|
198
247
|
|
|
199
|
-
[Unreleased]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.
|
|
248
|
+
[Unreleased]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.4...HEAD
|
|
249
|
+
[0.3.4]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.3...v0.3.4
|
|
250
|
+
[0.3.3]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.2...v0.3.3
|
|
200
251
|
[0.3.2]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.1...v0.3.2
|
|
201
252
|
[0.3.1]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.0...v0.3.1
|
|
202
253
|
[0.3.0]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.2.1...v0.3.0
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# ai-push-hooks
|
|
2
2
|
|
|
3
|
-
[](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml) [](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml) [](https://www.npmjs.com/package/ai-push-hooks)
|
|
4
4
|
|
|
5
5
|
**Agentic linting for the rules your coding agent forgot.**
|
|
6
6
|
|
|
7
7
|
`AGENTS.md` tells an agent how to work. **ai-push-hooks checks whether it followed through.** Think of it as the inverse of `AGENTS.md`: a second pass over outgoing changes before `git push`, catching guidelines the agent forgot or neglected.
|
|
8
8
|
|
|
9
|
-
Use **OpenCode, Codex, or
|
|
9
|
+
Use **OpenCode, Codex, Claude Code, or your own agentic CLI** to check rules that need judgment, not just a regex. Report violations, apply scoped fixes, and block pushes with explicit checks. It's a hedge against missed instructions, not a guarantee that AI catches everything.
|
|
10
10
|
|
|
11
11
|
## Rules Worth Checking
|
|
12
12
|
|
|
@@ -19,16 +19,18 @@ Use **OpenCode, Codex, or Claude Code** to check rules that need judgment, not j
|
|
|
19
19
|
|
|
20
20
|
Write your own rules in prompts or supply a rules file as context. These are examples of checks you configure, not built-in guarantees.
|
|
21
21
|
|
|
22
|
-
## Why
|
|
22
|
+
## Why This Tool?
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Keep your coding workflow; add a check before Git sends the commits. ai-push-hooks combines repository-specific AI checks, scripts, and explicit pass/fail gates around the outgoing diff, using your existing coding CLIs. No daemon or hosted review service is required.
|
|
25
|
+
|
|
26
|
+
Use it alongside [Lefthook](https://github.com/evilmartians/lefthook) for hook management and [roborev](https://github.com/kenn-io/roborev) for background reviews. [Archon](https://github.com/coleam00/Archon) and [TAKT](https://github.com/nrslib/takt) orchestrate development tasks; this tool deliberately stays narrower. Its job is to check your rules before a push, not take over how you build the project.
|
|
25
27
|
|
|
26
28
|
## Quick Start
|
|
27
29
|
|
|
28
30
|
Install the [ai-push-hooks skill](https://github.com/shanebishop1/ai-push-hooks/blob/main/skills/ai-push-hooks/SKILL.md), tell your agent what intelligent checks you want before pushes, and let it set up the modules for you.
|
|
29
31
|
|
|
30
32
|
```bash
|
|
31
|
-
npm install --save-dev ai-push-hooks
|
|
33
|
+
npm install --save-dev ai-push-hooks
|
|
32
34
|
npx --no-install ai-push-hooks init
|
|
33
35
|
npx --no-install ai-push-hooks install
|
|
34
36
|
```
|
|
@@ -37,12 +39,6 @@ Install and authenticate your chosen AI CLI. The current `init` starter checks d
|
|
|
37
39
|
|
|
38
40
|
[Other installation options](docs/configuration.md#installation) | [Existing hook managers](docs/configuration.md#hook-managers)
|
|
39
41
|
|
|
40
|
-
## Deterministic Demo
|
|
41
|
-
|
|
42
|
-

|
|
43
|
-
|
|
44
|
-
This generated GIF summarizes actual hook results from a disposable repository with a local deterministic command runner; it is not a live screen recording or live AI. It makes no provider calls, remote pushes, or Beads changes. Regenerate it with `uv run --no-project --with pillow==11.3.0 python docs/demo/generate.py`.
|
|
45
|
-
|
|
46
42
|
## Example: Check Your Rules
|
|
47
43
|
|
|
48
44
|
This workflow supplies the outgoing diff and your root `AGENTS.md` explicitly, asks for violations, and blocks if any are reported. It does not rely on the runner automatically loading agent instructions.
|
|
@@ -103,7 +99,7 @@ inputs = ["review/issues.json"]
|
|
|
103
99
|
|
|
104
100
|
`docs_issue_array` is the existing schema name for `{file, description}` findings; it works for code rules too. A missing rules file or a failed check blocks the push by default. Findings live in the run artifacts under `.git/ai-push-hooks/`.
|
|
105
101
|
|
|
106
|
-
**Want fixes too?** Add an `apply` step with the findings, your rules, and an explicit `allow_paths` list, then recheck and run tests. Applied edits are not auto-committed: review the diff, commit approved changes, and retry the push.
|
|
102
|
+
**Want fixes too?** Add an `apply` step with the findings, your rules, and an explicit `allow_paths` list, then recheck and run tests. [Runner completion is not proof that a fix landed](docs/configuration.md#deterministic-postconditions-after-apply): verify the result. Applied edits are not auto-committed: review the diff, commit approved changes, and retry the push.
|
|
107
103
|
|
|
108
104
|
## Build Your Workflow
|
|
109
105
|
|
|
@@ -121,7 +117,7 @@ Each module combines the steps it needs:
|
|
|
121
117
|
|
|
122
118
|
## Choose Your AI
|
|
123
119
|
|
|
124
|
-
OpenCode defaults to **`openai/gpt-5.6-luna`**. Explicit runner profiles use their own model settings. To use Codex or Claude Code, add its profile and change `[llm].runner`:
|
|
120
|
+
OpenCode, Codex, and Claude Code have first-class adapters. OpenCode defaults to **`openai/gpt-5.6-luna`**. Explicit runner profiles use their own model settings. To use Codex or Claude Code, add its profile and change `[llm].runner`:
|
|
125
121
|
|
|
126
122
|
```toml
|
|
127
123
|
[runners.codex]
|
|
@@ -131,13 +127,14 @@ type = "codex"
|
|
|
131
127
|
type = "claude"
|
|
132
128
|
```
|
|
133
129
|
|
|
134
|
-
Each `ask` or `apply` step can override the runner, so one tool can review and another can fix.
|
|
130
|
+
Each `ask` or `apply` step can override the runner, so one tool can review and another can fix. **Any other agentic CLI, including Pi, can integrate through a [custom command runner](docs/configuration.md#custom-runners)**: supply a noninteractive command or a thin wrapper. Use model identifiers available to your provider.
|
|
135
131
|
|
|
136
132
|
## Control And Safety
|
|
137
133
|
|
|
138
134
|
- Errors block pushes by default. AI judgments can still miss violations or report false positives.
|
|
139
135
|
- `apply` validates file and Git state and limits propagated edits to `allow_paths`. It does not auto-commit.
|
|
140
136
|
- Runners, scripts, and callbacks are trusted local programs, not an OS sandbox.
|
|
137
|
+
- Local hooks can be bypassed; retain CI for required enforcement.
|
|
141
138
|
- Repository content may be sent to your model provider. Review its privacy and billing terms.
|
|
142
139
|
|
|
143
140
|
Logs and run summaries live under `.git/ai-push-hooks/`. OpenCode transcripts are captured there by default. To intentionally skip one push: `AI_PUSH_HOOKS_SKIP=1 git push`.
|
package/SECURITY.md
CHANGED
|
@@ -40,6 +40,14 @@ transcript files as proof that provider-side data was deleted.
|
|
|
40
40
|
|
|
41
41
|
## Repository callbacks and commands
|
|
42
42
|
|
|
43
|
+
The npm launcher uses Python isolated mode to prevent the consuming checkout,
|
|
44
|
+
inherited `PYTHONPATH`, and the user site directory from shadowing the shipped
|
|
45
|
+
launcher or standard-library modules. It retains the selected interpreter's
|
|
46
|
+
normal system/virtual-environment site-packages for callback dependencies; use
|
|
47
|
+
that environment rather than `--user` installs or `PYTHONPATH`. This is launch
|
|
48
|
+
path isolation, not an operating-system sandbox, and trusted interpreter
|
|
49
|
+
startup customization or callback code is not made safe by it.
|
|
50
|
+
|
|
43
51
|
The published `0.3.0` beta includes the `ask` spelling, repository Python
|
|
44
52
|
callbacks, and direct `exec`/`assert` commands. The previous `0.2.1` beta used
|
|
45
53
|
`llm` for model-backed workflow steps; there is no compatibility alias, so
|
|
@@ -70,9 +78,11 @@ default command timeout is 60 seconds. stdout/stderr are private, unredacted
|
|
|
70
78
|
is capped at 16 MiB; invalid UTF-8, timeout, signal, missing executable, or
|
|
71
79
|
truncation fails closed. A command may explicitly invoke `bash -c`, and
|
|
72
80
|
`exec`/`assert` commands may modify the real checkout, so these are user-policy
|
|
73
|
-
choices rather than host isolation guarantees.
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
choices rather than host isolation guarantees. Failed command diagnostics name
|
|
82
|
+
the step, status/error class, and private artifact namespace only; they never
|
|
83
|
+
include captured stream text. `assert` saves its report before blocking on a
|
|
84
|
+
false/nonzero result. Only the workflow-level fail-open setting overrides that
|
|
85
|
+
block; there is no per-command override.
|
|
76
86
|
|
|
77
87
|
Read-only `collect` callback work may overlap up to `max_parallel`; trusted
|
|
78
88
|
callback/command authors must provide their own concurrency safety. `exec` and
|
|
@@ -117,7 +127,12 @@ The [0.3.0 release record](CHANGELOG.md#030---2026-09-09) documented a pinned
|
|
|
117
127
|
Lefthook suite reporting **407 tests with no skips**, an OpenCode **1.18.29**
|
|
118
128
|
contract smoke test with an in-process loopback mock provider and no external
|
|
119
129
|
model call, and version/help-only checks for Codex **0.148.0** and Claude
|
|
120
|
-
**2.1.220**.
|
|
121
|
-
|
|
130
|
+
**2.1.220**. The bounded validation snapshot also records Codex **0.152.0** on
|
|
131
|
+
2026-09-14: its synthetic fixture verified a nonce read and an allowlisted marker
|
|
132
|
+
apply followed by a deterministic postcondition. See the [runner validation
|
|
133
|
+
snapshot](docs/configuration.md#live-validation-snapshot). Together, these records
|
|
134
|
+
are historical or bounded evidence, not a current suite result or proof for every
|
|
135
|
+
provider, model, authentication mode, platform, live `apply` path, or a general
|
|
136
|
+
security guarantee.
|
|
122
137
|
Treat generated-hook path checks and the Lefthook runner as integration
|
|
123
138
|
safeguards, not isolation boundaries.
|
package/bin/ai-push-hooks.js
CHANGED
|
@@ -5,17 +5,22 @@ const fs = require('node:fs');
|
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
|
|
7
7
|
const packageRoot = path.resolve(__dirname, '..');
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const args = ['-
|
|
8
|
+
const bootstrap = path.join(packageRoot, 'bin', 'bootstrap.py');
|
|
9
|
+
const capabilityEnvironment = 'AI_PUSH_HOOKS_INTERNAL_CAPABILITY';
|
|
10
|
+
const args = ['-I', bootstrap, ...process.argv.slice(2)];
|
|
11
11
|
const pythonCommands = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3', 'python'];
|
|
12
12
|
|
|
13
|
-
function buildEnv() {
|
|
13
|
+
function buildEnv(capability = false) {
|
|
14
14
|
const env = { ...process.env };
|
|
15
15
|
env.AI_PUSH_HOOKS_NODE_EXECUTABLE = process.execPath;
|
|
16
16
|
env.AI_PUSH_HOOKS_NODE_SCRIPT = fs.realpathSync(__filename);
|
|
17
|
-
//
|
|
18
|
-
|
|
17
|
+
// -I intentionally ignores PYTHONPATH. The bootstrap adds only shipped paths.
|
|
18
|
+
delete env.PYTHONPATH;
|
|
19
|
+
if (capability) {
|
|
20
|
+
env[capabilityEnvironment] = '1';
|
|
21
|
+
} else {
|
|
22
|
+
delete env[capabilityEnvironment];
|
|
23
|
+
}
|
|
19
24
|
return env;
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -29,11 +34,8 @@ function run(command) {
|
|
|
29
34
|
function canRunPackage(command) {
|
|
30
35
|
const check = spawnSync(
|
|
31
36
|
command,
|
|
32
|
-
[
|
|
33
|
-
|
|
34
|
-
'import sys; assert sys.version_info >= (3, 10); __import__("tomllib" if sys.version_info >= (3, 11) else "tomli")',
|
|
35
|
-
],
|
|
36
|
-
{ stdio: 'ignore', env: buildEnv() },
|
|
37
|
+
['-I', bootstrap],
|
|
38
|
+
{ stdio: 'ignore', env: buildEnv(true) },
|
|
37
39
|
);
|
|
38
40
|
return check.status === 0;
|
|
39
41
|
}
|
package/bin/bootstrap.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Isolated entry point for the npm launcher.
|
|
2
|
+
|
|
3
|
+
The npm package is not a Python installation. This file adds only the Python
|
|
4
|
+
sources and the vendored Python 3.10 TOML dependency shipped beside it, while
|
|
5
|
+
leaving the selected interpreter's regular site-packages available for user
|
|
6
|
+
callbacks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import runpy
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_PACKAGE_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
18
|
+
_SHIPPED_SOURCE = _PACKAGE_ROOT / "src"
|
|
19
|
+
_SHIPPED_TOMLI = _PACKAGE_ROOT / "vendor" / "tomli-2.4.0-py3-none-any.whl"
|
|
20
|
+
_CAPABILITY_ENVIRONMENT = "AI_PUSH_HOOKS_INTERNAL_CAPABILITY"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _without_working_directory(entries: list[str]) -> list[str]:
|
|
24
|
+
working_directory = os.path.realpath(os.getcwd())
|
|
25
|
+
safe_entries: list[str] = []
|
|
26
|
+
for entry in entries:
|
|
27
|
+
if not entry or not os.path.isabs(entry):
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
if os.path.realpath(entry) == working_directory:
|
|
31
|
+
continue
|
|
32
|
+
except OSError:
|
|
33
|
+
continue
|
|
34
|
+
safe_entries.append(entry)
|
|
35
|
+
return safe_entries
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _configure_imports() -> None:
|
|
39
|
+
"""Retain interpreter site-packages and add only shipped import paths."""
|
|
40
|
+
sys.path[:] = _without_working_directory(sys.path)
|
|
41
|
+
sys.path.insert(0, str(_SHIPPED_TOMLI))
|
|
42
|
+
sys.path.insert(0, str(_SHIPPED_SOURCE))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _has_supported_toml() -> bool:
|
|
46
|
+
if sys.version_info < (3, 10):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
if sys.version_info >= (3, 11):
|
|
50
|
+
import tomllib # noqa: F401
|
|
51
|
+
else:
|
|
52
|
+
import tomli # noqa: F401
|
|
53
|
+
except ImportError:
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
_configure_imports()
|
|
60
|
+
if not _has_supported_toml():
|
|
61
|
+
return 1
|
|
62
|
+
if os.environ.get(_CAPABILITY_ENVIRONMENT) == "1":
|
|
63
|
+
return 0
|
|
64
|
+
runpy.run_module("ai_push_hooks", run_name="__main__")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
raise SystemExit(main())
|
package/docs/configuration.md
CHANGED
|
@@ -4,12 +4,14 @@ Define workflows in `ai-push-hooks.toml` at the repository root. Start with the
|
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
|
-
The [npm quick start](../README.md#quick-start) installs a repository-local wrapper.
|
|
7
|
+
The [npm quick start](../README.md#quick-start) installs a repository-local wrapper. Its tested/supported Python range is 3.10-3.13; the Python code requires 3.10+ and the launcher may probe newer interpreters, but newer versions are not part of that compatibility claim. The npm package does not bundle Python. The wrapper starts the selected interpreter in Python isolated mode: it ignores `PYTHONPATH` and the user site directory, then adds only the package's shipped `src` and vendored Tomli wheel. The interpreter's normal system or virtual-environment site-packages remain available.
|
|
8
|
+
|
|
9
|
+
If a repository callback needs an external dependency, install it with the exact Python executable the wrapper will select (for example, `/path/to/python -m pip install <dependency>`), into that interpreter's normal system or virtual-environment site-packages. The wrapper tests candidates in its ordered list (`python3.14`, `python3.13`, `python3.12`, `python3.11`, `python3.10`, `python3`, then `python`), so activating a virtual environment alone does not guarantee that it wins over a higher-priority executable elsewhere on `PATH`; make the intended candidate discoverable first. Do not rely on `pip install --user` or `PYTHONPATH`; they are intentionally not part of the npm launch path.
|
|
8
10
|
|
|
9
11
|
For pnpm:
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
|
-
pnpm add -D ai-push-hooks
|
|
14
|
+
pnpm add -D ai-push-hooks
|
|
13
15
|
pnpm exec ai-push-hooks init --template minimal-docs
|
|
14
16
|
pnpm exec ai-push-hooks install
|
|
15
17
|
```
|
|
@@ -22,7 +24,7 @@ ai-push-hooks init --template minimal-docs
|
|
|
22
24
|
ai-push-hooks install
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
`uv tool install ai-push-hooks` or `pipx install ai-push-hooks` can replace the pip command.
|
|
27
|
+
`uv tool install ai-push-hooks` or `pipx install ai-push-hooks` can replace the pip command.
|
|
26
28
|
|
|
27
29
|
AI steps need the selected CLI installed and authenticated with an available model. Deterministic-only workflows do not need one. `init` and `install` refuse to overwrite existing files; `--force` explicitly replaces them.
|
|
28
30
|
|
|
@@ -64,6 +66,8 @@ Upgrading from 0.2.1? Rename `type = "llm"` steps to `type = "ask"`.
|
|
|
64
66
|
|
|
65
67
|
`[llm].runner` selects the default; a step's `runner` overrides it. Referenced profiles must exist under `[runners.<name>]`, except for the implicit OpenCode default.
|
|
66
68
|
|
|
69
|
+
OpenCode, Codex, and Claude are first-class adapters. The generic `command` type is for other agentic CLIs and follows the custom runner contract below.
|
|
70
|
+
|
|
67
71
|
| Profile field | Values / behavior |
|
|
68
72
|
| --- | --- |
|
|
69
73
|
| `type` | Required: `opencode`, `codex`, `claude`, or `command`. |
|
|
@@ -81,6 +85,27 @@ OpenCode runs with isolated configuration and permissions; project/global config
|
|
|
81
85
|
|
|
82
86
|
Apply requires a single pushed branch whose local commit is the checked-out `HEAD`. Staging excludes Git metadata, `AGENTS.md`, ignored files, symlinks, and special files. These controls are not an OS sandbox or an automatic rollback system. See [Security](../SECURITY.md).
|
|
83
87
|
|
|
88
|
+
### Live validation snapshot
|
|
89
|
+
|
|
90
|
+
Live read/review and apply checks on Linux:
|
|
91
|
+
|
|
92
|
+
| Date | Runner (CLI; model) | Review | Apply |
|
|
93
|
+
| --- | --- | --- | --- |
|
|
94
|
+
| 2026-09-12 | OpenCode (1.18.29; `openai/gpt-5.6-luna`) | Passed | Passed |
|
|
95
|
+
| 2026-09-12 | Claude Code (2.1.220; `sonnet`) | Passed | Passed |
|
|
96
|
+
| 2026-09-14 | Codex (0.152.0; `gpt-5.6-luna`) | Passed | Passed |
|
|
97
|
+
|
|
98
|
+
The Codex check read a random value from a disposable repository, then verified
|
|
99
|
+
that only the allowlisted README changed. These checks establish the tested
|
|
100
|
+
operations, not compatibility with every model or platform.
|
|
101
|
+
|
|
102
|
+
#### Local troubleshooting: Codex sandbox
|
|
103
|
+
|
|
104
|
+
For Codex 0.152, the standalone no-AI sandbox syntax is
|
|
105
|
+
`codex sandbox -- /usr/bin/true`. Resolve host restrictions rather than disabling
|
|
106
|
+
protections, and use a deterministic postcondition to verify the desired checkout
|
|
107
|
+
outcome after an apply.
|
|
108
|
+
|
|
84
109
|
### Apply and manual commits
|
|
85
110
|
|
|
86
111
|
`apply` is generic: it can edit any eligible checkout file matching `allow_paths`; it is not limited to Markdown. The runner edits a temporary staging copy, and only validated changes propagate back to the checkout. Those edits do not enter the commit already being pushed, and `apply` never creates a Git commit.
|
|
@@ -97,9 +122,62 @@ inputs = ["apply/result.json"]
|
|
|
97
122
|
|
|
98
123
|
The assertion checks `apply/result.json`'s `changed_files` and intentionally blocks when edits were propagated. Review `git diff`, run the relevant checks, commit the approved changes, and retry the push. On the retry, the assertion passes when the apply step reports no changes.
|
|
99
124
|
|
|
125
|
+
### Deterministic postconditions after apply
|
|
126
|
+
|
|
127
|
+
An apply process succeeding, or reporting `changed_files = []`, is not proof that
|
|
128
|
+
the requested result is present. The latter can simply mean that the apply was a
|
|
129
|
+
legitimate no-op because the checkout was already correct. Add a deterministic
|
|
130
|
+
postcondition after `apply` and before the manual-commit gate when the desired
|
|
131
|
+
file content has a precise representation:
|
|
132
|
+
|
|
133
|
+
```toml
|
|
134
|
+
[general]
|
|
135
|
+
require_clean_worktree = true
|
|
136
|
+
|
|
137
|
+
[workflow]
|
|
138
|
+
modules = ["docs"]
|
|
139
|
+
|
|
140
|
+
[modules.docs]
|
|
141
|
+
enabled = true
|
|
142
|
+
|
|
143
|
+
[[modules.docs.steps]]
|
|
144
|
+
id = "apply"
|
|
145
|
+
type = "apply"
|
|
146
|
+
prompt = "In the existing README.md, replace 'Release note: DRAFT.' with 'Release note: READY.' and make no other changes."
|
|
147
|
+
allow_paths = ["README.md"]
|
|
148
|
+
|
|
149
|
+
[[modules.docs.steps]]
|
|
150
|
+
id = "postcondition"
|
|
151
|
+
type = "assert"
|
|
152
|
+
command = [
|
|
153
|
+
"{python}",
|
|
154
|
+
"-c",
|
|
155
|
+
"import pathlib, sys; sys.exit(0 if pathlib.Path('README.md').read_text(encoding='utf-8') == 'Release note: READY.\\n' else 1)",
|
|
156
|
+
]
|
|
157
|
+
inputs = ["apply/result.json"]
|
|
158
|
+
|
|
159
|
+
[[modules.docs.steps]]
|
|
160
|
+
id = "manual-commit"
|
|
161
|
+
type = "assert"
|
|
162
|
+
assertion = "docs_apply_requires_manual_commit"
|
|
163
|
+
inputs = ["apply/result.json"]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
This is an intentional synthetic example, not a recommendation to overwrite a
|
|
167
|
+
real README: its fixture starts with exactly `Release note: DRAFT.\n`, and its
|
|
168
|
+
desired full content is exactly `Release note: READY.\n`.
|
|
169
|
+
The command is a direct argv vector rather than a Python `assert`; Python
|
|
170
|
+
optimization must not be able to remove the check. The postcondition reads the
|
|
171
|
+
checkout after apply, not the commit being pushed. Keep the clean-worktree
|
|
172
|
+
workflow setting for the hook's starting state and the manual-commit gate for
|
|
173
|
+
the intentionally dirty post-apply checkout: review the resulting diff, run
|
|
174
|
+
relevant checks, commit it, and retry the push. This gate and postcondition
|
|
175
|
+
still do not prove human review, semantic correctness, or that an agent
|
|
176
|
+
complied with every instruction.
|
|
177
|
+
|
|
100
178
|
## Custom Runners
|
|
101
179
|
|
|
102
|
-
Use a command profile
|
|
180
|
+
Use a `command` profile to invoke any other agentic CLI, including Pi, directly or through a thin wrapper. A wrapper can normalize JSONL events into a final response. The runner must be noninteractive: read the prompt from stdin or argv, write only the final response to stdout, and use exit codes to report success or failure.
|
|
103
181
|
|
|
104
182
|
```toml
|
|
105
183
|
[runners.custom]
|
|
@@ -111,7 +189,7 @@ project_access = "project"
|
|
|
111
189
|
|
|
112
190
|
Select it with `runner = "custom"` on an `ask` or `apply` step. The runner receives the full instruction and artifact packet. For apply, its working directory is the staging copy.
|
|
113
191
|
|
|
114
|
-
Commands are argv arrays, not shell strings. Whole-argument placeholders are `{model}`, `{cwd}`, `{stage}`, and `{prompt}`. The `argv` transport requires exactly one `{prompt}` argument; stdin avoids exposing prompts in process listings. Custom programs inherit the user environment and
|
|
192
|
+
Commands are argv arrays, not shell strings. Whole-argument placeholders are `{model}`, `{cwd}`, `{stage}`, and `{prompt}`. The `argv` transport requires exactly one `{prompt}` argument; stdin avoids exposing prompts in process listings. Custom programs inherit the user's environment, including authentication variables, and manage their own permissions and session setup/cleanup. For apply, the working directory is the staging copy.
|
|
115
193
|
|
|
116
194
|
## Commands And Callbacks
|
|
117
195
|
|
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.3.
|
|
7
|
+
version = "0.3.4"
|
|
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"
|
|
@@ -12,7 +12,7 @@ license = "MIT"
|
|
|
12
12
|
authors = [{ name = "Shane Bishop" }]
|
|
13
13
|
keywords = ["git", "lefthook", "docs", "ai", "pre-push"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
18
|
"Programming Language :: Python :: 3 :: Only",
|
package/src/ai_push_hooks/cli.py
CHANGED
|
@@ -10,6 +10,7 @@ from .hook import run_hook
|
|
|
10
10
|
from .install import install_hook
|
|
11
11
|
from .paths import path_is_link_or_reparse, write_text_no_follow
|
|
12
12
|
from .prompts_builtin import MINIMAL_DOCS_TEMPLATE
|
|
13
|
+
from .executors.runners import RunnerError
|
|
13
14
|
from .types import HookError
|
|
14
15
|
|
|
15
16
|
|
|
@@ -117,6 +118,6 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
117
118
|
if args.command == "install":
|
|
118
119
|
return install_hook(args.force)
|
|
119
120
|
raise HookError(f"Unknown command: {args.command}")
|
|
120
|
-
except HookError as exc:
|
|
121
|
+
except (HookError, RunnerError) as exc:
|
|
121
122
|
sys.stderr.write(f"[ai-push-hooks] {exc}\n")
|
|
122
123
|
return 1
|
|
@@ -213,6 +213,20 @@ def _runtime_metadata_namespaces(context: RuntimeContext) -> tuple[pathlib.Path,
|
|
|
213
213
|
return namespaces
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
def _resolve_effective_hooks_path(context: RuntimeContext) -> pathlib.Path:
|
|
217
|
+
raw_hooks_path = run_command(
|
|
218
|
+
["git", "rev-parse", "--git-path", "hooks"],
|
|
219
|
+
cwd=context.repo_root,
|
|
220
|
+
check=True,
|
|
221
|
+
).stdout.strip()
|
|
222
|
+
if not raw_hooks_path:
|
|
223
|
+
raise HookError("Git did not return an effective hooks path")
|
|
224
|
+
hooks_path = pathlib.Path(raw_hooks_path)
|
|
225
|
+
if not hooks_path.is_absolute():
|
|
226
|
+
hooks_path = context.repo_root / hooks_path
|
|
227
|
+
return pathlib.Path(os.path.abspath(hooks_path))
|
|
228
|
+
|
|
229
|
+
|
|
216
230
|
def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
|
|
217
231
|
git_dir = context.git_dir.resolve(strict=True)
|
|
218
232
|
common_dir = resolve_git_common_dir(context.repo_root).resolve(strict=True)
|
|
@@ -323,15 +337,7 @@ def _snapshot_git_control_metadata(context: RuntimeContext) -> MetadataSnapshot:
|
|
|
323
337
|
for name in ("HEAD", "config", "config.worktree", "packed-refs"):
|
|
324
338
|
record(f"shared:{name}", common_dir / name)
|
|
325
339
|
scan_tree("shared:refs", common_dir / "refs")
|
|
326
|
-
|
|
327
|
-
["git", "rev-parse", "--git-path", "hooks"],
|
|
328
|
-
cwd=context.repo_root,
|
|
329
|
-
check=True,
|
|
330
|
-
).stdout.strip()
|
|
331
|
-
hooks_path = pathlib.Path(raw_hooks_path)
|
|
332
|
-
if not hooks_path.is_absolute():
|
|
333
|
-
hooks_path = context.repo_root / hooks_path
|
|
334
|
-
hooks_path = pathlib.Path(os.path.abspath(hooks_path))
|
|
340
|
+
hooks_path = _resolve_effective_hooks_path(context)
|
|
335
341
|
if any(is_path_within(hooks_path, namespace) for namespace in excluded_namespaces):
|
|
336
342
|
raise HookError(
|
|
337
343
|
"Configured core.hooksPath must not overlap ai-push-hooks runtime metadata: "
|
|
@@ -582,6 +588,12 @@ def _safe_destination(context: RuntimeContext, relative_path: str) -> pathlib.Pa
|
|
|
582
588
|
raise HookError(
|
|
583
589
|
f"Apply destination resolves inside Git metadata: {relative_path}"
|
|
584
590
|
)
|
|
591
|
+
hooks_path = _resolve_effective_hooks_path(context).resolve(strict=False)
|
|
592
|
+
if is_path_within(resolved_destination, hooks_path):
|
|
593
|
+
raise HookError(
|
|
594
|
+
f"Apply destination resolves inside configured Git hooks path: "
|
|
595
|
+
f"{relative_path}"
|
|
596
|
+
)
|
|
585
597
|
return destination
|
|
586
598
|
|
|
587
599
|
|
|
@@ -602,6 +614,12 @@ def _preflight_apply_operations(
|
|
|
602
614
|
context: RuntimeContext,
|
|
603
615
|
operations: list[ApplyOperation],
|
|
604
616
|
) -> None:
|
|
617
|
+
# Validate every destination before the propagation loop can create a
|
|
618
|
+
# parent directory or write even the first operation. In particular, a
|
|
619
|
+
# configured core.hooksPath may not exist yet, so this guard cannot depend
|
|
620
|
+
# on the hooks tree being present in the checkout.
|
|
621
|
+
for operation in operations:
|
|
622
|
+
_safe_destination(context, operation.relative_path)
|
|
605
623
|
conflicts = [
|
|
606
624
|
operation.relative_path
|
|
607
625
|
for operation in operations
|
|
@@ -647,7 +665,6 @@ def _propagate_staging_changes(
|
|
|
647
665
|
operations: list[ApplyOperation] = []
|
|
648
666
|
expected: dict[str, StagedFile | None] = {}
|
|
649
667
|
for relative_path in sorted(changed_paths):
|
|
650
|
-
_safe_destination(context, relative_path)
|
|
651
668
|
baseline = baselines.get(relative_path, DestinationState("missing"))
|
|
652
669
|
staged = after.get(relative_path)
|
|
653
670
|
if staged is None:
|
|
@@ -63,14 +63,20 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
|
|
|
63
63
|
if schema == "beads_alignment_result":
|
|
64
64
|
if not isinstance(payload, dict):
|
|
65
65
|
raise HookError("Expected schema beads_alignment_result")
|
|
66
|
-
|
|
67
|
-
if
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
unresolved = payload.get("unresolved")
|
|
67
|
+
if type(unresolved) is not bool:
|
|
68
|
+
raise HookError("beads_alignment_result.unresolved must be a boolean")
|
|
69
|
+
if "commands" in payload and (
|
|
70
|
+
not isinstance(payload["commands"], list)
|
|
71
|
+
or not all(isinstance(item, str) for item in payload["commands"])
|
|
70
72
|
):
|
|
71
73
|
raise HookError(
|
|
72
74
|
"beads_alignment_result.commands must be an array of strings"
|
|
73
75
|
)
|
|
76
|
+
if "report_markdown" in payload and not isinstance(
|
|
77
|
+
payload["report_markdown"], str
|
|
78
|
+
):
|
|
79
|
+
raise HookError("beads_alignment_result.report_markdown must be a string")
|
|
74
80
|
return payload
|
|
75
81
|
if schema == "pr_create_payload":
|
|
76
82
|
if not isinstance(payload, dict):
|
|
@@ -5,6 +5,7 @@ import pathlib
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from ..types import RuntimeContext, StepConfig
|
|
8
|
+
from .ask import validate_schema
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def docs_apply_requires_manual_commit(
|
|
@@ -28,8 +29,10 @@ def beads_alignment_clean(
|
|
|
28
29
|
_step: StepConfig,
|
|
29
30
|
inputs: list[pathlib.Path],
|
|
30
31
|
) -> dict[str, Any]:
|
|
31
|
-
payload =
|
|
32
|
-
|
|
32
|
+
payload = validate_schema(
|
|
33
|
+
"beads_alignment_result", json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
34
|
+
)
|
|
35
|
+
unresolved = payload["unresolved"]
|
|
33
36
|
if unresolved:
|
|
34
37
|
return {
|
|
35
38
|
"ok": False,
|
|
@@ -209,12 +209,10 @@ def beads_alignment_executor(
|
|
|
209
209
|
"report_written": False,
|
|
210
210
|
"unresolved": False,
|
|
211
211
|
}
|
|
212
|
-
payload =
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
payload = validate_schema(
|
|
213
|
+
"beads_alignment_result", json.loads(inputs[0].read_text(encoding="utf-8"))
|
|
214
|
+
)
|
|
215
215
|
commands = payload.get("commands", [])
|
|
216
|
-
if not isinstance(commands, list):
|
|
217
|
-
raise HookError("beads_alignment commands must be an array")
|
|
218
216
|
if len(commands) > BEADS_ALIGNMENT_MAX_COMMANDS:
|
|
219
217
|
raise HookError(
|
|
220
218
|
f"beads_alignment accepts at most {BEADS_ALIGNMENT_MAX_COMMANDS} commands"
|
|
@@ -245,8 +243,8 @@ def beads_alignment_executor(
|
|
|
245
243
|
)
|
|
246
244
|
commands_run.append(command)
|
|
247
245
|
|
|
248
|
-
report_markdown =
|
|
249
|
-
unresolved =
|
|
246
|
+
report_markdown = payload.get("report_markdown", "").strip()
|
|
247
|
+
unresolved = payload["unresolved"]
|
|
250
248
|
report_written = False
|
|
251
249
|
if report_markdown:
|
|
252
250
|
if not report_markdown.endswith("\n"):
|
|
@@ -403,25 +403,15 @@ def strip_terminal_controls(value: str) -> str:
|
|
|
403
403
|
return _UNSAFE_TERMINAL_CONTROL_PATTERN.sub("", without_ansi)
|
|
404
404
|
|
|
405
405
|
|
|
406
|
-
def
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
redacted = strip_terminal_controls(value)
|
|
410
|
-
for pattern in _SECRET_PATTERNS:
|
|
411
|
-
redacted = pattern.sub(
|
|
412
|
-
lambda match: (
|
|
413
|
-
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
|
|
414
|
-
),
|
|
415
|
-
redacted,
|
|
416
|
-
)
|
|
417
|
-
redaction_values: list[str] = []
|
|
406
|
+
def _diagnostic_redaction_values(secrets: Sequence[str]) -> tuple[str, ...]:
|
|
407
|
+
values: list[str] = []
|
|
418
408
|
seen: set[str] = set()
|
|
419
409
|
fragment_count = 0
|
|
420
410
|
for secret in secrets:
|
|
421
411
|
if not isinstance(secret, str) or not secret or secret in seen:
|
|
422
412
|
continue
|
|
423
413
|
seen.add(secret)
|
|
424
|
-
|
|
414
|
+
values.append(secret)
|
|
425
415
|
# A bounded fragment set catches a child echoing one prompt token,
|
|
426
416
|
# while skipping a whole large diff avoids the old O(words * stream)
|
|
427
417
|
# behavior. The request-sensitive helper additionally suppresses
|
|
@@ -430,12 +420,72 @@ def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
|
|
|
430
420
|
for fragment in _DIAGNOSTIC_FRAGMENT_PATTERN.findall(secret):
|
|
431
421
|
if fragment not in seen:
|
|
432
422
|
seen.add(fragment)
|
|
433
|
-
|
|
423
|
+
values.append(fragment)
|
|
434
424
|
fragment_count += 1
|
|
435
425
|
if fragment_count >= 512:
|
|
436
426
|
break
|
|
437
427
|
if fragment_count >= 512:
|
|
438
428
|
continue
|
|
429
|
+
return tuple(values)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _longest_prefix_suffix(value: str, prefix: str) -> int:
|
|
433
|
+
"""Return the longest suffix of ``value`` that is a prefix of ``prefix``."""
|
|
434
|
+
|
|
435
|
+
limit = min(len(value), len(prefix))
|
|
436
|
+
if limit == 0:
|
|
437
|
+
return 0
|
|
438
|
+
pattern = prefix[:limit]
|
|
439
|
+
failure = [0] * limit
|
|
440
|
+
matched = 0
|
|
441
|
+
for index in range(1, limit):
|
|
442
|
+
while matched and pattern[matched] != pattern[index]:
|
|
443
|
+
matched = failure[matched - 1]
|
|
444
|
+
if pattern[matched] == pattern[index]:
|
|
445
|
+
matched += 1
|
|
446
|
+
failure[index] = matched
|
|
447
|
+
|
|
448
|
+
matched = 0
|
|
449
|
+
for character in value[-limit:]:
|
|
450
|
+
while matched and (matched == limit or pattern[matched] != character):
|
|
451
|
+
matched = failure[matched - 1]
|
|
452
|
+
if pattern[matched] == character:
|
|
453
|
+
matched += 1
|
|
454
|
+
return matched
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _boundary_redaction_start(value: str, secrets: Sequence[str]) -> int | None:
|
|
458
|
+
"""Find the earliest visible prefix of a known value at a cutoff."""
|
|
459
|
+
|
|
460
|
+
earliest: int | None = None
|
|
461
|
+
for secret in secrets:
|
|
462
|
+
if len(secret) <= 1:
|
|
463
|
+
continue
|
|
464
|
+
# The captured child stream may itself end while a known value is
|
|
465
|
+
# being emitted, so do not require the value's unseen suffix to exist
|
|
466
|
+
# in ``value``. This comparison is bounded by the preview and secret
|
|
467
|
+
# prefix, rather than scanning an unbounded stream tail.
|
|
468
|
+
prefix = secret[: min(len(value), len(secret) - 1)]
|
|
469
|
+
overlap = _longest_prefix_suffix(value, prefix)
|
|
470
|
+
if overlap:
|
|
471
|
+
start = len(value) - overlap
|
|
472
|
+
if earliest is None or start < earliest:
|
|
473
|
+
earliest = start
|
|
474
|
+
return earliest
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
|
|
478
|
+
"""Redact supplied secrets and common credential-shaped values."""
|
|
479
|
+
|
|
480
|
+
redacted = strip_terminal_controls(value)
|
|
481
|
+
for pattern in _SECRET_PATTERNS:
|
|
482
|
+
redacted = pattern.sub(
|
|
483
|
+
lambda match: (
|
|
484
|
+
f"{match.group(1)}[REDACTED]" if match.lastindex else "[REDACTED]"
|
|
485
|
+
),
|
|
486
|
+
redacted,
|
|
487
|
+
)
|
|
488
|
+
redaction_values = _diagnostic_redaction_values(secrets)
|
|
439
489
|
for secret in sorted(redaction_values, key=len, reverse=True):
|
|
440
490
|
redacted = redacted.replace(secret, "[REDACTED]")
|
|
441
491
|
return redacted
|
|
@@ -454,10 +504,18 @@ def bounded_diagnostic(
|
|
|
454
504
|
raw = str(value)
|
|
455
505
|
# Redact only the bounded preview. Anything after this point cannot be
|
|
456
506
|
# present in the returned diagnostic, and therefore does not need a scan.
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
|
|
507
|
+
# A known value can start in the preview and finish after it, though; mask
|
|
508
|
+
# that visible prefix after ordinary redaction so a complete match cannot
|
|
509
|
+
# be destroyed first. Boundary matching is bounded even for large
|
|
510
|
+
# streams/secrets.
|
|
511
|
+
preview = strip_terminal_controls(raw[:max_chars])
|
|
512
|
+
redaction_values = _diagnostic_redaction_values(secrets)
|
|
460
513
|
safe = redact_diagnostic(preview, secrets=secrets)
|
|
514
|
+
boundary_start = _boundary_redaction_start(safe, redaction_values)
|
|
515
|
+
if boundary_start is not None:
|
|
516
|
+
# Several known values can overlap at the cutoff. Replace their
|
|
517
|
+
# union once so insertion cannot invalidate another span's index.
|
|
518
|
+
safe = safe[:boundary_start] + "[REDACTED]"
|
|
461
519
|
if len(raw) <= max_chars and len(safe) <= max_chars:
|
|
462
520
|
return safe
|
|
463
521
|
if max_chars <= len(DIAGNOSTIC_TRUNCATION_MARKER):
|
|
@@ -9,7 +9,6 @@ engine integration lane.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import math
|
|
12
|
-
import os
|
|
13
12
|
import pathlib
|
|
14
13
|
import re
|
|
15
14
|
import sys
|
|
@@ -26,7 +25,12 @@ from ..types import (
|
|
|
26
25
|
RuntimeContext,
|
|
27
26
|
StepConfig,
|
|
28
27
|
)
|
|
29
|
-
from .runners.contracts import
|
|
28
|
+
from .runners.contracts import (
|
|
29
|
+
RunnerError,
|
|
30
|
+
RunnerExecutableNotFoundError,
|
|
31
|
+
RunnerSignalError,
|
|
32
|
+
RunnerTimeoutError,
|
|
33
|
+
)
|
|
30
34
|
from .runners.process import (
|
|
31
35
|
DEFAULT_MAX_OUTPUT_BYTES,
|
|
32
36
|
ProcessResult,
|
|
@@ -297,14 +301,26 @@ def run_step_command(
|
|
|
297
301
|
inputs=input_paths,
|
|
298
302
|
python_executable=python_executable or sys.executable,
|
|
299
303
|
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
304
|
+
try:
|
|
305
|
+
process_result = run_process(
|
|
306
|
+
argv,
|
|
307
|
+
cwd=pathlib.Path(repo_root).resolve(strict=True),
|
|
308
|
+
input_path=input_path,
|
|
309
|
+
timeout_seconds=timeout_seconds,
|
|
310
|
+
env=None,
|
|
311
|
+
max_output_bytes=max_output_bytes,
|
|
312
|
+
)
|
|
313
|
+
except RunnerError as error:
|
|
314
|
+
# Do not retain process causes: they can contain rendered argv or
|
|
315
|
+
# captured stream bytes when displayed as an exception chain.
|
|
316
|
+
error.__cause__ = None
|
|
317
|
+
error.__suppress_context__ = True
|
|
318
|
+
if isinstance(error, RunnerExecutableNotFoundError):
|
|
319
|
+
# The command vector is configuration-controlled and may contain
|
|
320
|
+
# credentials. Do not let the process layer's executable detail
|
|
321
|
+
# cross the step-command boundary.
|
|
322
|
+
error.args = ("step command executable was not found",)
|
|
323
|
+
raise
|
|
308
324
|
result = _from_process_result(process_result)
|
|
309
325
|
if result.stdout_truncated or result.stderr_truncated:
|
|
310
326
|
streams = " and ".join(
|
|
@@ -320,31 +336,31 @@ def run_step_command(
|
|
|
320
336
|
)
|
|
321
337
|
error._step_command_result = result
|
|
322
338
|
raise error
|
|
323
|
-
|
|
339
|
+
try:
|
|
340
|
+
_validate_utf8(result)
|
|
341
|
+
except StepCommandEncodingError as error:
|
|
342
|
+
error.__cause__ = None
|
|
343
|
+
error.__suppress_context__ = True
|
|
344
|
+
raise
|
|
324
345
|
return result
|
|
325
346
|
|
|
326
347
|
|
|
327
|
-
def
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
safe = redact_diagnostic(combined, secrets=_environment_secrets())
|
|
344
|
-
return (
|
|
345
|
-
bounded_diagnostic(safe, max_chars=1_200)
|
|
346
|
-
or f"command exited with status {result.returncode}"
|
|
347
|
-
)
|
|
348
|
+
def _command_failure_status(
|
|
349
|
+
error: BaseException, result: StepCommandResult | None
|
|
350
|
+
) -> str:
|
|
351
|
+
if isinstance(error, RunnerTimeoutError):
|
|
352
|
+
return "timeout"
|
|
353
|
+
if isinstance(error, RunnerSignalError):
|
|
354
|
+
return "signal"
|
|
355
|
+
if isinstance(error, StepCommandEncodingError):
|
|
356
|
+
return "invalid_utf8"
|
|
357
|
+
if isinstance(error, StepCommandTruncatedError):
|
|
358
|
+
return "capture_limit"
|
|
359
|
+
if isinstance(error, RunnerExecutableNotFoundError):
|
|
360
|
+
return "not_started"
|
|
361
|
+
if result is not None:
|
|
362
|
+
return str(result.returncode)
|
|
363
|
+
return "error"
|
|
348
364
|
|
|
349
365
|
|
|
350
366
|
def step_command_result_payload(
|
|
@@ -368,7 +384,7 @@ def step_command_result_payload(
|
|
|
368
384
|
if step_type == "assert":
|
|
369
385
|
payload["ok"] = result.returncode == 0
|
|
370
386
|
if result.returncode != 0:
|
|
371
|
-
payload["message"] =
|
|
387
|
+
payload["message"] = f"command exited with status {result.returncode}"
|
|
372
388
|
return payload
|
|
373
389
|
|
|
374
390
|
|
|
@@ -447,6 +463,7 @@ def execute_step_command(
|
|
|
447
463
|
input_map = dict(zip(step.inputs, inputs))
|
|
448
464
|
store = artifacts or ArtifactStore(context.run_dir)
|
|
449
465
|
store.prepare()
|
|
466
|
+
persisted: PersistedStepCommandResult | None = None
|
|
450
467
|
try:
|
|
451
468
|
process_result = run_step_command(
|
|
452
469
|
step.command,
|
|
@@ -459,42 +476,63 @@ def execute_step_command(
|
|
|
459
476
|
else DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS
|
|
460
477
|
),
|
|
461
478
|
)
|
|
479
|
+
payload = step_command_result_payload(process_result, step_type=step.type)
|
|
480
|
+
persisted = _persist_process_result(
|
|
481
|
+
store, state, step, process_result, payload=payload
|
|
482
|
+
)
|
|
483
|
+
if step.type == "exec" and process_result.returncode != 0:
|
|
484
|
+
error = StepCommandExecutionError(
|
|
485
|
+
"step exec command returned a non-zero status"
|
|
486
|
+
)
|
|
487
|
+
error._step_command_result = process_result
|
|
488
|
+
error._step_command_persisted = persisted
|
|
489
|
+
raise error
|
|
490
|
+
if step.type == "assert" and process_result.returncode != 0:
|
|
491
|
+
error = StepCommandAssertionError(str(payload["message"]))
|
|
492
|
+
error._step_command_result = process_result
|
|
493
|
+
error._step_command_persisted = persisted
|
|
494
|
+
raise error
|
|
462
495
|
except BaseException as error:
|
|
463
496
|
captured = _result_from_error(error)
|
|
464
497
|
if captured is None:
|
|
465
498
|
captured = getattr(error, "_step_command_result", None)
|
|
466
|
-
if isinstance(captured, StepCommandResult)
|
|
467
|
-
|
|
468
|
-
if
|
|
469
|
-
report =
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
499
|
+
captured_result = captured if isinstance(captured, StepCommandResult) else None
|
|
500
|
+
if captured_result is not None:
|
|
501
|
+
if persisted is None:
|
|
502
|
+
report = None
|
|
503
|
+
if isinstance(error, StepCommandEncodingError):
|
|
504
|
+
report = step_command_result_payload(
|
|
505
|
+
captured_result, step_type=step.type
|
|
506
|
+
)
|
|
507
|
+
report["malformed"] = True
|
|
508
|
+
report["message"] = "command output was not valid UTF-8"
|
|
509
|
+
persisted = _persist_process_result(
|
|
510
|
+
store,
|
|
511
|
+
state,
|
|
512
|
+
step,
|
|
513
|
+
captured_result,
|
|
514
|
+
payload=report,
|
|
515
|
+
)
|
|
479
516
|
error._step_command_persisted = persisted
|
|
517
|
+
if isinstance(error, (RunnerError, StepCommandError)):
|
|
518
|
+
namespace = f"{state.module.id}/{state.step_index:02d}-{step.id}"
|
|
519
|
+
run_id = getattr(context, "run_id", store.run_dir.name)
|
|
520
|
+
location_suffix = "" if persisted is not None else " (not created)"
|
|
521
|
+
returncode = (
|
|
522
|
+
f"; returncode={captured_result.returncode}"
|
|
523
|
+
if captured_result is not None
|
|
524
|
+
else ""
|
|
525
|
+
)
|
|
526
|
+
error.args = (
|
|
527
|
+
f"command step={state.module.id}.{step.id} failed; "
|
|
528
|
+
f"status={_command_failure_status(error, captured_result)}; "
|
|
529
|
+
f"error_class={error.__class__.__name__}; "
|
|
530
|
+
f"artifact_location={run_id}/{namespace}{location_suffix}"
|
|
531
|
+
f"{returncode}",
|
|
532
|
+
)
|
|
480
533
|
raise
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
persisted = _persist_process_result(
|
|
484
|
-
store, state, step, process_result, payload=payload
|
|
485
|
-
)
|
|
486
|
-
if step.type == "exec" and process_result.returncode != 0:
|
|
487
|
-
error = StepCommandExecutionError(
|
|
488
|
-
"step exec command returned a non-zero status"
|
|
489
|
-
)
|
|
490
|
-
error._step_command_result = process_result
|
|
491
|
-
error._step_command_persisted = persisted
|
|
492
|
-
raise error
|
|
493
|
-
if step.type == "assert" and process_result.returncode != 0:
|
|
494
|
-
error = StepCommandAssertionError(str(payload["message"]))
|
|
495
|
-
error._step_command_result = process_result
|
|
496
|
-
error._step_command_persisted = persisted
|
|
497
|
-
raise error
|
|
534
|
+
if persisted is None: # pragma: no cover - successful persistence is required above
|
|
535
|
+
raise StepCommandError("step command result was not persisted")
|
|
498
536
|
return persisted
|
|
499
537
|
|
|
500
538
|
|
|
@@ -543,8 +543,11 @@ def collect_changed_files(repo_root: pathlib.Path, ranges: list[str]) -> list[st
|
|
|
543
543
|
[
|
|
544
544
|
"git",
|
|
545
545
|
"diff",
|
|
546
|
+
"--no-ext-diff",
|
|
547
|
+
"--no-textconv",
|
|
548
|
+
"--no-color",
|
|
546
549
|
"--name-only",
|
|
547
|
-
"--diff-filter=
|
|
550
|
+
"--diff-filter=ACMRDT",
|
|
548
551
|
"-z",
|
|
549
552
|
range_expr,
|
|
550
553
|
],
|
|
@@ -639,7 +642,15 @@ def collect_diff(repo_root: pathlib.Path, ranges: list[str], max_bytes: int) ->
|
|
|
639
642
|
|
|
640
643
|
body, body_truncated = _collect_bounded_git_diff(
|
|
641
644
|
repo_root,
|
|
642
|
-
[
|
|
645
|
+
[
|
|
646
|
+
"git",
|
|
647
|
+
"diff",
|
|
648
|
+
"--no-ext-diff",
|
|
649
|
+
"--no-textconv",
|
|
650
|
+
"--no-color",
|
|
651
|
+
"--unified=3",
|
|
652
|
+
range_expr,
|
|
653
|
+
],
|
|
643
654
|
limit - len(output),
|
|
644
655
|
)
|
|
645
656
|
if not body_truncated:
|
|
@@ -21,11 +21,11 @@ from .git_utils import (
|
|
|
21
21
|
current_branch,
|
|
22
22
|
ensure_dir,
|
|
23
23
|
env_bool,
|
|
24
|
-
git,
|
|
25
24
|
parse_push_updates,
|
|
26
25
|
resolve_git_dir,
|
|
27
26
|
resolve_repo_root,
|
|
28
27
|
resolve_storage_path,
|
|
28
|
+
run_command,
|
|
29
29
|
should_skip_for_sync_branch,
|
|
30
30
|
unique_range_expressions,
|
|
31
31
|
)
|
|
@@ -65,7 +65,10 @@ def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def _assert_clean_worktree(repo_root: pathlib.Path) -> None:
|
|
68
|
-
|
|
68
|
+
completed = run_command(["git", "status", "--short"], cwd=repo_root, check=False)
|
|
69
|
+
if completed.returncode != 0:
|
|
70
|
+
raise HookError("Hook requires a clean worktree but Git status failed")
|
|
71
|
+
status = (completed.stdout or "").strip()
|
|
69
72
|
if status:
|
|
70
73
|
raise HookError("Hook requires a clean worktree but local changes are present")
|
|
71
74
|
|
|
@@ -9,7 +9,13 @@ from pathlib import PurePosixPath
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from ..types import CollectorResult, RuntimeContext
|
|
12
|
-
from ..git_utils import
|
|
12
|
+
from ..git_utils import (
|
|
13
|
+
collect_commit_messages_for_ranges,
|
|
14
|
+
git,
|
|
15
|
+
path_matches,
|
|
16
|
+
run_command,
|
|
17
|
+
)
|
|
18
|
+
from ..paths import path_is_link_or_reparse
|
|
13
19
|
|
|
14
20
|
DOC_INCLUDE_PATTERNS = ("README.md", "docs/**/*.md")
|
|
15
21
|
DOC_IGNORE_PATTERNS = ("docs/archive/**",)
|
|
@@ -34,7 +40,7 @@ def _is_safe_doc_file(repo_root: pathlib.Path, candidate: pathlib.Path) -> bool:
|
|
|
34
40
|
current = repo_root
|
|
35
41
|
for part in relative.parts:
|
|
36
42
|
current /= part
|
|
37
|
-
if
|
|
43
|
+
if path_is_link_or_reparse(current):
|
|
38
44
|
return False
|
|
39
45
|
candidate_stat = candidate.lstat()
|
|
40
46
|
if not stat.S_ISREG(candidate_stat.st_mode):
|
|
@@ -47,18 +53,40 @@ def _is_safe_doc_file(repo_root: pathlib.Path, candidate: pathlib.Path) -> bool:
|
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
def _expand_doc_files(repo_root: pathlib.Path) -> list[pathlib.Path]:
|
|
56
|
+
"""List safe, relevant docs from Git's tracked/unignored file inventory.
|
|
57
|
+
|
|
58
|
+
``--cached`` retains tracked files even when an ignore rule matches them;
|
|
59
|
+
``--others --exclude-standard`` adds only untracked files that Git would
|
|
60
|
+
consider visible. Enumerating this way avoids walking ignored directories
|
|
61
|
+
before any document content is opened.
|
|
62
|
+
"""
|
|
50
63
|
repo_root = repo_root.resolve(strict=True)
|
|
64
|
+
listed = run_command(
|
|
65
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
66
|
+
cwd=repo_root,
|
|
67
|
+
check=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
51
70
|
files: list[pathlib.Path] = []
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
for relative in listed.stdout.split("\x00"):
|
|
73
|
+
if not relative or relative in seen:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(relative)
|
|
54
76
|
if not _path_matches(relative, DOC_INCLUDE_PATTERNS):
|
|
55
77
|
continue
|
|
56
78
|
if _path_matches(relative, DOC_IGNORE_PATTERNS):
|
|
57
79
|
continue
|
|
80
|
+
# Git emits POSIX separators even on platforms where a backslash has
|
|
81
|
+
# path semantics. Reject such ambiguous names there rather than
|
|
82
|
+
# allowing a listed filename to change the lexical containment check.
|
|
83
|
+
if os.name == "nt" and "\\" in relative:
|
|
84
|
+
continue
|
|
85
|
+
candidate = repo_root.joinpath(*relative.split("/"))
|
|
58
86
|
if not _is_safe_doc_file(repo_root, candidate):
|
|
59
87
|
continue
|
|
60
88
|
files.append(candidate)
|
|
61
|
-
return sorted(files)
|
|
89
|
+
return sorted(files, key=lambda path: path.relative_to(repo_root).as_posix())
|
|
62
90
|
|
|
63
91
|
|
|
64
92
|
def _deterministic_seed_queries(diff_text: str, changed_files: list[str]) -> list[str]:
|
|
@@ -104,6 +104,18 @@ def resolve_contained_path(base: pathlib.Path, raw: str, label: str) -> pathlib.
|
|
|
104
104
|
return resolved_candidate
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def _ensure_private_directory_component(directory: pathlib.Path) -> None:
|
|
108
|
+
try:
|
|
109
|
+
directory.mkdir(mode=PRIVATE_DIRECTORY_MODE)
|
|
110
|
+
except FileExistsError:
|
|
111
|
+
# Tolerate a concurrent creator winning the mkdir race, then validate
|
|
112
|
+
# the result before changing its permissions.
|
|
113
|
+
pass
|
|
114
|
+
if path_is_link_or_reparse(directory) or not directory.is_dir():
|
|
115
|
+
raise HookError(f"Private runtime path is not a safe directory: {directory}")
|
|
116
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
117
|
+
|
|
118
|
+
|
|
107
119
|
def ensure_private_directory(
|
|
108
120
|
path: pathlib.Path,
|
|
109
121
|
*,
|
|
@@ -119,8 +131,7 @@ def ensure_private_directory(
|
|
|
119
131
|
if path_is_link_or_reparse(current) or not current.is_dir():
|
|
120
132
|
raise HookError(f"Private runtime directory has an unsafe parent: {path}")
|
|
121
133
|
for directory in reversed(missing):
|
|
122
|
-
directory
|
|
123
|
-
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
134
|
+
_ensure_private_directory_component(directory)
|
|
124
135
|
if path_is_link_or_reparse(target) or not target.is_dir():
|
|
125
136
|
raise HookError(f"Private runtime directory is unsafe: {path}")
|
|
126
137
|
os.chmod(target, PRIVATE_DIRECTORY_MODE)
|
|
@@ -141,11 +152,12 @@ def ensure_private_directory(
|
|
|
141
152
|
raise HookError(
|
|
142
153
|
f"Private runtime directory traverses a symlink or reparse point: {directory}"
|
|
143
154
|
)
|
|
144
|
-
if
|
|
145
|
-
directory.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
if directory.exists():
|
|
156
|
+
if not directory.is_dir():
|
|
157
|
+
raise HookError(f"Private runtime path is not a directory: {directory}")
|
|
158
|
+
os.chmod(directory, PRIVATE_DIRECTORY_MODE)
|
|
159
|
+
else:
|
|
160
|
+
_ensure_private_directory_component(directory)
|
|
149
161
|
return target
|
|
150
162
|
|
|
151
163
|
|
|
@@ -13,10 +13,12 @@ from __future__ import annotations # noqa: I001
|
|
|
13
13
|
import errno
|
|
14
14
|
import hashlib
|
|
15
15
|
import inspect
|
|
16
|
+
import itertools
|
|
16
17
|
import os
|
|
17
18
|
import pathlib
|
|
18
19
|
import re
|
|
19
20
|
import stat
|
|
21
|
+
import sys
|
|
20
22
|
import threading
|
|
21
23
|
from collections.abc import Callable, Mapping, Sequence
|
|
22
24
|
from types import ModuleType
|
|
@@ -35,6 +37,8 @@ _CALLABLE_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
|
35
37
|
_SOURCE_ENCODING = "utf-8"
|
|
36
38
|
PLUGIN_SOURCE_MAX_BYTES = 1 * 1024 * 1024
|
|
37
39
|
_SOURCE_READ_CHUNK_BYTES = 64 * 1024
|
|
40
|
+
_MODULE_NUMBER = itertools.count(1)
|
|
41
|
+
_MODULE_NUMBER_LOCK = threading.Lock()
|
|
38
42
|
_OS_OPEN = os.open
|
|
39
43
|
_DESCRIPTOR_RELATIVE_SUPPORTED = bool(
|
|
40
44
|
getattr(os, "O_DIRECTORY", 0)
|
|
@@ -221,7 +225,6 @@ class PluginLoader:
|
|
|
221
225
|
self._cache: dict[tuple[pathlib.Path, pathlib.Path], ModuleType] = {}
|
|
222
226
|
self._lock = threading.RLock()
|
|
223
227
|
self._source_locks: dict[tuple[pathlib.Path, pathlib.Path], threading.Lock] = {}
|
|
224
|
-
self._module_number = 0
|
|
225
228
|
|
|
226
229
|
def _source_lock(self, key: tuple[pathlib.Path, pathlib.Path]) -> threading.Lock:
|
|
227
230
|
with self._lock:
|
|
@@ -248,7 +251,12 @@ class PluginLoader:
|
|
|
248
251
|
# to the lexical cache key for a safe repository file.
|
|
249
252
|
try:
|
|
250
253
|
source_text = source.decode(_SOURCE_ENCODING)
|
|
251
|
-
code = compile(
|
|
254
|
+
code = compile(
|
|
255
|
+
source_text,
|
|
256
|
+
str(callback_path),
|
|
257
|
+
"exec",
|
|
258
|
+
dont_inherit=True,
|
|
259
|
+
)
|
|
252
260
|
except UnicodeDecodeError:
|
|
253
261
|
raise HookError(
|
|
254
262
|
f"Python plugin {relative_path}:{callable_name} source is not valid UTF-8"
|
|
@@ -258,16 +266,18 @@ class PluginLoader:
|
|
|
258
266
|
f"Python plugin {relative_path}:{callable_name} could not be compiled"
|
|
259
267
|
) from None
|
|
260
268
|
|
|
261
|
-
with
|
|
262
|
-
|
|
263
|
-
module_number = self._module_number
|
|
269
|
+
with _MODULE_NUMBER_LOCK:
|
|
270
|
+
module_number = next(_MODULE_NUMBER)
|
|
264
271
|
digest = hashlib.sha256(f"{root}\0{callback_path}".encode()).hexdigest()[:16]
|
|
265
272
|
module_name = f"ai_push_hooks_plugin_{digest}_{module_number}"
|
|
266
273
|
module = ModuleType(module_name)
|
|
267
274
|
module.__file__ = str(callback_path)
|
|
268
275
|
module.__package__ = ""
|
|
276
|
+
imported = False
|
|
277
|
+
sys.modules[module_name] = module
|
|
269
278
|
try:
|
|
270
279
|
exec(code, module.__dict__) # noqa: S102
|
|
280
|
+
imported = True
|
|
271
281
|
except KeyboardInterrupt:
|
|
272
282
|
raise
|
|
273
283
|
except SystemExit:
|
|
@@ -290,6 +300,9 @@ class PluginLoader:
|
|
|
290
300
|
raise HookError(
|
|
291
301
|
f"Python plugin {relative_path}:{callable_name} failed during import"
|
|
292
302
|
) from None
|
|
303
|
+
finally:
|
|
304
|
+
if not imported and sys.modules.get(module_name) is module:
|
|
305
|
+
del sys.modules[module_name]
|
|
293
306
|
return module
|
|
294
307
|
|
|
295
308
|
def load(self, repo_root: pathlib.Path, reference: str) -> Callable[..., Any]:
|
package/vendor/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The npm package includes the unmodified pure-Python wheel for [Tomli](https://pypi.org/project/tomli/2.4.0/). Python 3.10 uses it to parse TOML; Python 3.11+ uses the standard-library `tomllib` module.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The npm bootstrap adds the wheel directly to Python's import path, so no pip install, install script, or runtime download is needed. It does not inherit `PYTHONPATH`. The wheel includes Tomli's MIT license under `tomli-2.4.0.dist-info/licenses/LICENSE`.
|
|
6
6
|
|
|
7
7
|
To reproduce the bundled download from the repository root:
|
|
8
8
|
|