ai-push-hooks 0.3.3 → 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 CHANGED
@@ -4,6 +4,36 @@ 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
+
7
37
  ## [0.3.3] - 2026-09-14
8
38
 
9
39
  This is a beta patch release. Python and npm both use `0.3.3`, the canonical
@@ -215,7 +245,8 @@ the exact `0.2.0` version rather than a PyPI beta channel.
215
245
  - Preserved Git porcelain paths and protected pre-existing dirty allowlisted files during apply steps.
216
246
  - Clarified module-local artifact references and standardized the repository hook integration.
217
247
 
218
- [Unreleased]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.3...HEAD
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
219
250
  [0.3.3]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.2...v0.3.3
220
251
  [0.3.2]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.1...v0.3.2
221
252
  [0.3.1]: https://github.com/shanebishop1/ai-push-hooks/compare/v0.3.0...v0.3.1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ai-push-hooks
2
2
 
3
- [![CI](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml/badge.svg)](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml) [![npm beta](https://img.shields.io/npm/v/ai-push-hooks/beta?label=npm%20beta)](https://www.npmjs.com/package/ai-push-hooks/v/beta)
3
+ [![CI](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml/badge.svg)](https://github.com/shanebishop1/ai-push-hooks/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/ai-push-hooks)](https://www.npmjs.com/package/ai-push-hooks)
4
4
 
5
5
  **Agentic linting for the rules your coding agent forgot.**
6
6
 
@@ -30,7 +30,7 @@ Use it alongside [Lefthook](https://github.com/evilmartians/lefthook) for hook m
30
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.
31
31
 
32
32
  ```bash
33
- npm install --save-dev ai-push-hooks@beta
33
+ npm install --save-dev ai-push-hooks
34
34
  npx --no-install ai-push-hooks init
35
35
  npx --no-install ai-push-hooks install
36
36
  ```
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. `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.
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
@@ -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 srcDir = path.join(packageRoot, 'src');
9
- const tomliWheel = path.join(packageRoot, 'vendor', 'tomli-2.4.0-py3-none-any.whl');
10
- const args = ['-m', 'ai_push_hooks', ...process.argv.slice(2)];
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
- // Pure-Python wheels are importable archives; no pip or install scripts needed.
18
- env.PYTHONPATH = [srcDir, tomliWheel, env.PYTHONPATH].filter(Boolean).join(path.delimiter);
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
- '-c',
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
  }
@@ -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())
@@ -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. It needs Node 18+ and Python 3.10-3.13 on the hook's `PATH`; the npm package does not bundle Python.
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@beta
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. npm uses the `beta` tag while the package is in beta.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-push-hooks",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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.3.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 :: 4 - Beta",
15
+ "Development Status :: 5 - Production/Stable",
16
16
  "Intended Audience :: Developers",
17
17
  "Programming Language :: Python :: 3",
18
18
  "Programming Language :: Python :: 3 :: Only",
@@ -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
- raw_hooks_path = run_command(
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
- commands = payload.get("commands", [])
67
- if commands is not None and (
68
- not isinstance(commands, list)
69
- or not all(isinstance(item, str) for item in commands)
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 = json.loads(inputs[0].read_text(encoding="utf-8"))
32
- unresolved = bool(payload.get("unresolved", False))
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 = json.loads(inputs[0].read_text(encoding="utf-8"))
213
- if not isinstance(payload, dict):
214
- raise HookError("beads_alignment payload must be an object")
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 = str(payload.get("report_markdown", "")).strip()
249
- unresolved = bool(payload.get("unresolved", False))
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 redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
407
- """Redact supplied secrets and common credential-shaped values."""
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
- redaction_values.append(secret)
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
- redaction_values.append(fragment)
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
- # Credential-shaped values which reach the preview boundary are still
458
- # handled by the regex redactor before the preview is returned.
459
- preview = raw[:max_chars]
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 bounded_diagnostic, redact_diagnostic
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
- process_result = run_process(
301
- argv,
302
- cwd=pathlib.Path(repo_root).resolve(strict=True),
303
- input_path=input_path,
304
- timeout_seconds=timeout_seconds,
305
- env=None,
306
- max_output_bytes=max_output_bytes,
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
- _validate_utf8(result)
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 _environment_secrets() -> tuple[str, ...]:
328
- markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
329
- return tuple(
330
- value
331
- for name, value in os.environ.items()
332
- if any(marker in name.upper() for marker in markers) and value
333
- )
334
-
335
-
336
- def _assert_message(result: StepCommandResult) -> str:
337
- # A process error can occur before the normal strict UTF-8 validation pass.
338
- # Surrogateescape keeps that diagnostic bounded and non-throwing; the
339
- # private stream artifacts still retain the exact original bytes.
340
- stdout = result.stdout.decode("utf-8", errors="surrogateescape")
341
- stderr = result.stderr.decode("utf-8", errors="surrogateescape")
342
- combined = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
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"] = _assert_message(result)
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
- report = None
468
- if isinstance(error, StepCommandEncodingError):
469
- report = step_command_result_payload(captured, step_type=step.type)
470
- report["malformed"] = True
471
- report["message"] = "command output was not valid UTF-8"
472
- persisted = _persist_process_result(
473
- store,
474
- state,
475
- step,
476
- captured,
477
- payload=report,
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
- payload = step_command_result_payload(process_result, step_type=step.type)
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=ACMRD",
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
- ["git", "diff", "--unified=3", range_expr],
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
- status = git(repo_root, ["status", "--short"], check=False).strip()
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 collect_commit_messages_for_ranges, git, path_matches
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 stat.S_ISLNK(current.lstat().st_mode):
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
- for candidate in repo_root.rglob("*.md"):
53
- relative = candidate.relative_to(repo_root).as_posix()
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.mkdir(mode=PRIVATE_DIRECTORY_MODE)
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 not directory.exists():
145
- directory.mkdir(mode=PRIVATE_DIRECTORY_MODE)
146
- if not directory.is_dir():
147
- raise HookError(f"Private runtime path is not a directory: {directory}")
148
- os.chmod(directory, PRIVATE_DIRECTORY_MODE)
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(source_text, str(callback_path), "exec")
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 self._lock:
262
- self._module_number += 1
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 wrapper adds the wheel to `PYTHONPATH`, so no pip install, install script, or runtime download is needed. The wheel includes Tomli's MIT license under `tomli-2.4.0.dist-info/licenses/LICENSE`.
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