Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ jobs:
assert context == "session_start", output
PY

- name: Smoke test retained hook launcher on Python 3.9
env:
HOOK_TEST_PYTHON: ${{ steps.runtime-python.outputs.python-path }}
TOOLCHAIN_PYTHON: ${{ steps.toolchain-python.outputs.python-path }}
run: >-
uv run --frozen --python "$TOOLCHAIN_PYTHON"
pytest tests/instruction_hub/test_hook_bundles.py -q -k python_floor

managed-runtime-windows:
runs-on: windows-latest
steps:
Expand Down
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,11 +698,47 @@ its first workspace root. A missing working directory remains null.

Emit `{"context": "Message for the agent"}` on stdout, or leave stdout empty to
stay quiet. Use stderr for diagnostics. The adapter wraps context in the native
response envelope and preserves a nonzero script exit. Scripts execute in the
adapter's process, so cancellation signals reach their handlers directly. The
adapter does not retry commands, start background workers, or infer recovery
response envelope. Portable hooks supply context; they cannot veto a tool or
turn. Their generated inline launcher reports failures on stderr and exits
successfully, including when an update removes the adapter or helper files.
Python's missing-script exit code 2 must not become an accidental host veto.
Use native JSON hooks for intentional blocking or other host decision semantics.
Scripts execute in the adapter's process, so cancellation signals reach their
handlers directly. The adapter does not retry commands, start background workers, or infer recovery
policy; those decisions belong to the hook script.

### Portable hook lifetime

Generated commands pin a SHA-256 manifest covering the adapter, hook declaration,
and every file in the hook bundle. A generated SessionStart handler prepares a
private, complete copy without executing authored code. The first ordinary hook
invocation also prepares the copy if needed. Files are verified before an atomic
directory rename makes the bundle available; concurrent preparations can share
only a verified copy of the same digest.

Retained bundles live under `$XDG_DATA_HOME/promptless/hook-runtime/v1`, defaulting
to `~/.local/share/promptless/hook-runtime/v1`. Set `PROMPTLESS_HOOK_DATA_HOME` to
override the base directory (the launcher appends `v1`). This store is independent
of each host's disposable plugin cache. A loaded command keeps using its exact
bundle after updates, rollback, or same-version replacement. It never follows a
"latest" pointer or loads code from a sibling plugin version. Hook dependencies
must be inside the authored bundle and located relative to `__file__`; the working
directory and host plugin-root environment variables retain their host meanings.
Use a separate writable location for hook state, not the retained code directory.

The launcher validates retained files before execution. Missing, changed, or
unexpected files skip the context hook with a nonblocking diagnostic, emitted
once until the bundle becomes usable again. No hook input or output is persisted
by the launcher. If the plugin disappears before either preparation or the first
invocation, the hook skips rather than executing a different version.

There is no automatic eviction: an idle or resumable session may still reference
any retained digest. Remove retained bundles only after closing sessions that
could use them, including sessions you intend to resume with old registrations.
Safe automated cleanup requires host-provided lifetime/lease information; an
age-only cleanup would recreate the update race. This retention applies to
portable declarations, not authored native JSON or managed trace-runtime hooks.

### Native JSON escape hatch

For other events, runtimes, or host features, keep `hooks.<target>.json` and shared
Expand Down
38 changes: 38 additions & 0 deletions src/promptless_instruction_hub/hook_bundles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Describe the complete portable hook bundle pinned by a generated command."""

from __future__ import annotations

import hashlib
import json
from pathlib import Path

from promptless_instruction_hub.fs import JsonValue
from promptless_instruction_hub.hook_launcher import RUNNER_NAME
from promptless_instruction_hub.models import Harness, LoadedAsset


def write_bundle_manifest(hook_root: Path, asset: LoadedAsset, target: Harness) -> str:
spec = asset.metadata.hook
assert spec is not None
paths = [hook_root / RUNNER_NAME, *(path for path in (hook_root / asset.id).rglob("*") if path.is_file())]
files: dict[str, JsonValue] = {
path.relative_to(hook_root).as_posix(): {
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"executable": bool(path.stat().st_mode & 0o111),
}
for path in sorted(paths)
}
manifest = {
"schema": 1,
"host": target,
"hook": asset.id,
"entrypoint": f"{asset.id}/{spec.entrypoint}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Normalize the entrypoint before storing it in the manifest. HookDefinition accepts paths such as ./handler.py and sub//handler.py, which previously executed successfully, but the manifest file keys are normalized by pathlib while this field preserves the original spelling. Consequently, read_manifest rejects these valid bundles as "incomplete hook manifest", and both preparation and ordinary invocations silently skip the hook on all targets. Construct this field with (Path(asset.id) / spec.entrypoint).as_posix() and add coverage for these accepted spellings.

"config": spec.model_dump(mode="json"),
"files": files,
}
payload = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()
digest = hashlib.sha256(payload).hexdigest()
path = hook_root / "_bundles" / f"{digest}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
return digest
27 changes: 18 additions & 9 deletions src/promptless_instruction_hub/hook_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
from __future__ import annotations

import shlex
from pathlib import Path

from promptless_instruction_hub import hook_launcher
from promptless_instruction_hub.errors import InstructionHubError
from promptless_instruction_hub.fs import JsonValue
from promptless_instruction_hub.hook_runtime import EVENTS
from promptless_instruction_hub.models import Harness, LoadedAsset

RUNNER_NAME = "_instruction_hub_hook.py"
PLUGIN_ROOTS = {"claude": "CLAUDE_PLUGIN_ROOT", "codex": "PLUGIN_ROOT", "cursor": "CURSOR_PLUGIN_ROOT"}


Expand Down Expand Up @@ -42,18 +43,17 @@ def validate_hook_definition(asset: LoadedAsset) -> None:
seen.add(key)


def render_hook_definition(asset: LoadedAsset, target: Harness) -> dict[str, JsonValue]:
def render_hook_definition(asset: LoadedAsset, target: Harness, digest: str) -> dict[str, JsonValue]:
spec = asset.metadata.hook
assert spec is not None
root = PLUGIN_ROOTS[target]
runner = f'"${{{root}}}"/{shlex.quote("hooks/" + RUNNER_NAME)}'
entrypoint = f'"${{{root}}}"/{shlex.quote(f"hooks/{asset.id}/{spec.entrypoint}")}'
command = (
"if command -v python3 >/dev/null 2>&1 "
"&& python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))'; then "
f"exec python3 {runner} --host {target} --entrypoint {entrypoint}; "
"else printf '%s\\n' 'Instruction Hub hooks require Python 3.9 or newer.' >&2; fi"
launcher = shlex.quote(Path(hook_launcher.__file__).read_text(encoding="utf-8"))
invocation = f'exec python3 -I -c {launcher} "${{{root}}}/hooks" {shlex.quote(digest)}'
prefix = (
"if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))'; then "
)
suffix = "; else printf '%s\\n' 'Instruction Hub hooks require Python 3.9 or newer.' >&2; fi"
command = prefix + invocation + suffix
events: dict[str, JsonValue] = {}
for binding in spec.bindings[target]:
action: dict[str, JsonValue] = {"command": command, "timeout": spec.timeout}
Expand All @@ -69,4 +69,13 @@ def render_hook_definition(asset: LoadedAsset, target: Harness) -> dict[str, Jso
handlers = events.setdefault(binding.event, [])
assert isinstance(handlers, list)
handlers.append(entry)
# Pin dependencies at session start, even for hooks that first run much
# later. Preparing only copies verified files; it never runs authored code.
startup = "sessionStart" if target == "cursor" else "SessionStart"
prepare: dict[str, JsonValue] = {"command": prefix + invocation + " --prepare" + suffix, "timeout": 5}
if target != "cursor":
prepare = {"hooks": [{"type": "command", **prepare}]}
startup_handlers = events.setdefault(startup, [])
assert isinstance(startup_handlers, list)
startup_handlers.insert(0, prepare)
return {"version": 1, "hooks": events} if target == "cursor" else {"hooks": events}
152 changes: 152 additions & 0 deletions src/promptless_instruction_hub/hook_launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Python 3.9+ launcher embedded in hook commands, independent of plugin files.

Portable hooks provide context, not policy decisions. Isolate their failures
here so Python's missing-script exit code 2 cannot become a native hook veto.
Keep this module standard-library-only and preserve in-process signal delivery.
"""

from __future__ import annotations

import errno
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import runpy
import stat
import sys
import tempfile

RUNNER_NAME = "_instruction_hub_hook.py"


def data_root() -> Path:
"""Use a host-independent location that plugin cache cleanup does not own."""
override = os.environ.get("PROMPTLESS_HOOK_DATA_HOME")
if override:
return Path(override).expanduser() / "v1"
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")
return base / "promptless/hook-runtime/v1"


def read_manifest(path: Path, digest: str) -> tuple[bytes, dict]:
payload = path.read_bytes()
if hashlib.sha256(payload).hexdigest() != digest:
raise ValueError("hook manifest digest mismatch")
manifest = json.loads(payload)
if manifest["schema"] != 1 or manifest["host"] not in {"claude", "codex", "cursor"}:
raise ValueError("unsupported hook bundle")
files = manifest["files"]
if RUNNER_NAME not in files or manifest["entrypoint"] not in files:
raise ValueError("incomplete hook manifest")
for name in files:
path_name = PurePosixPath(name)
if path_name.is_absolute() or ".." in path_name.parts or "\\" in name or name != path_name.as_posix():
raise ValueError("invalid hook bundle path")
return payload, manifest


def checked_file(root: Path, name: str, expected: dict) -> bytes:
path = root / name
# Source assets reject symlinks at build time. Check installed bundles too,
# including parent directories, before copying or executing their contents.
current = root
for part in ("", *PurePosixPath(name).parts):
current = current / part
if current.is_symlink():
raise ValueError("symlink in hook bundle")
if not stat.S_ISREG(path.stat().st_mode):
raise ValueError("non-regular file in hook bundle")
payload = path.read_bytes()
if hashlib.sha256(payload).hexdigest() != expected["sha256"]:
raise ValueError(f"hook file digest mismatch: {name}")
return payload


def retained_bundle(source: Path, digest: str) -> tuple[Path, dict]:
"""Publish a complete immutable bundle; never resolve to a newer version."""
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
raise ValueError("invalid hook bundle digest")
bundles = data_root() / "bundles"
destination = bundles / digest
if not destination.exists():
payload, manifest = read_manifest(source / "_bundles" / f"{digest}.json", digest)
bundles.mkdir(parents=True, exist_ok=True, mode=0o700)
with tempfile.TemporaryDirectory(prefix=".staging-", dir=bundles) as temporary:
staging = Path(temporary) / "bundle"
staging.mkdir(mode=0o700)
for name, expected in manifest["files"].items():
content = checked_file(source, name, expected)
target = staging / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
target.chmod(0o700 if expected["executable"] else 0o600)
(staging / "manifest.json").write_bytes(payload)
try:
staging.rename(destination)
except OSError as exc:
# Another hook may have published the same digest while we
# staged. It is safe to use only after validating that winner.
if exc.errno not in {errno.EEXIST, errno.ENOTEMPTY}:
raise
_, manifest = read_manifest(destination / "manifest.json", digest)
expected_paths = set(manifest["files"]) | {"manifest.json"}
for path in destination.rglob("*"):
if path.is_symlink() or path.is_file() and path.relative_to(destination).as_posix() not in expected_paths:
raise ValueError("unexpected file in retained hook bundle")
for name, expected in manifest["files"].items():
checked_file(destination, name, expected)
return destination, manifest


def report_unavailable(digest: str, exc: Exception) -> None:
"""Warn once per unavailable bundle; do not persist hook input or output."""
try:
warnings = data_root() / "warnings"
warnings.mkdir(parents=True, exist_ok=True, mode=0o700)
# Hash even malformed digests before using them as a filesystem key.
key = hashlib.sha256(digest.encode()).hexdigest()
with (warnings / key).open("x"):
pass
except FileExistsError:
return
except OSError:
# An unwritable data directory must remain visible and nonblocking.
pass
detail = str(exc).replace("\n", " ")[:500]
print(f"Instruction Hub context hook skipped: {type(exc).__name__}: {detail}", file=sys.stderr)


def main() -> None:
sys.dont_write_bytecode = True
source, digest, *options = sys.argv[1:]
try:
root, manifest = retained_bundle(Path(source), digest)
except Exception as exc:
# Cache removal, incomplete installs, and corrupt/unwritable storage
# must never turn this context hook into a host policy decision.
report_unavailable(digest, exc)
return
try:
key = hashlib.sha256(digest.encode()).hexdigest()
(data_root() / "warnings" / key).unlink(missing_ok=True)
except OSError:
pass # Diagnostic bookkeeping cannot prevent an otherwise healthy hook.
if options == ["--prepare"]:
return
runner = str(root / RUNNER_NAME)
sys.argv = [runner, "--host", manifest["host"], "--entrypoint", str(root / manifest["entrypoint"])]
try:
runpy.run_path(runner, run_name="__main__")
except SystemExit as exc:
if exc.code not in (None, 0):
print(f"Instruction Hub context hook skipped: adapter exited {exc.code!s:.100}.", file=sys.stderr)
except Exception as exc:
# This is the boundary for arbitrary authored Python, including import
# failures while an updater removes a bundle. Never swallow cancellation.
detail = str(exc).replace("\n", " ")[:500]
print(f"Instruction Hub context hook skipped: {type(exc).__name__}: {detail}", file=sys.stderr)


if __name__ == "__main__":
main()
10 changes: 8 additions & 2 deletions src/promptless_instruction_hub/render/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from promptless_instruction_hub.assets import METADATA_FILE
from promptless_instruction_hub.errors import InstructionHubError
from promptless_instruction_hub.fs import JsonValue, copy_tree, read_json_mapping, write_json
from promptless_instruction_hub.hook_definitions import RUNNER_NAME, render_hook_definition
from promptless_instruction_hub.hook_bundles import write_bundle_manifest
from promptless_instruction_hub.hook_definitions import render_hook_definition
from promptless_instruction_hub.hook_launcher import RUNNER_NAME
from promptless_instruction_hub.models import Harness, LoadedAsset


Expand All @@ -35,9 +37,11 @@ def render_native_hooks(target_root: Path, target: Harness, assets: list[LoadedA
continue
if asset.metadata.hook is not None:
source = asset.path / METADATA_FILE
config = render_hook_definition(asset, target)
hook_root.mkdir(parents=True, exist_ok=True)
shutil.copy2(hook_runtime.__file__, hook_root / RUNNER_NAME)
copy_tree(asset.path, hook_root / asset.id, skip_names={METADATA_FILE})
digest = write_bundle_manifest(hook_root, asset, target)
config = render_hook_definition(asset, target, digest)
else:
source = _config_path(asset, target)
config = _read_config(source)
Expand All @@ -58,6 +62,8 @@ def render_native_hooks(target_root: Path, target: Harness, assets: list[LoadedA
raise InstructionHubError(f"{source}: conflicting hook configuration field {key!r} for {target}")
else:
merged[key] = value
if asset.metadata.hook is not None:
continue # Already copied and hashed before rendering its command.
if asset.path.is_dir():
copy_tree(asset.path, hook_root / asset.id, skip_names={METADATA_FILE})
else:
Expand Down
Loading
Loading