Skip to content

hooks

hooks

Sandbox-owned git hooks the gate server injects into every agent push.

The gate's HTTP server historically pointed core.hooksPath at /dev/null so that no hook inside a gate repo could ever run — repo content must never become host-side code. This module keeps that property while inverting the mechanism: hooks now live in a directory the sandbox owns outside every gate repo, rendered from the constants below by the supervisor when it composes the gate (the server itself only receives the path). Repo content still can't inject code; the operator side gains exactly one enforcement point.

One hook is installed, post-receive, and it does two things:

  • Backs up destructive agent updates. For every pushed ref under refs/heads/ that was force-moved or deleted, the old tip is saved as refs/terok/backup/<branch>/<stamp>-<sha12> — the same scheme the sync model uses, so list_backups() / prune_backups() cover both sides with one retention policy. A backup is a ref, not a copy: the objects it pins arrived with the original push and are already reflog-retained, so the net-new storage is the ref file itself.
  • Writes the push marker. Every push overwrites $GIT_DIR/terok-push-marker with a timestamp and the updated refs, so the host can watch one file's mtime instead of polling refs.

Why post-receive and not update: git quarantines pushed objects while pre-receive/update run and refuses all ref updates during that window, so the backup ref cannot be written there. By post-receive the quarantine is over — and the old tips being backed up predate the push anyway. The cost is that a failed backup can only warn (the push is already accepted), not reject; the warning goes to the agent's push output and into the marker file, and the always-on reflog remains the last-resort trail.

Identity needs no plumbing at all: only agent pushes traverse the HTTP server, so everything these hooks see is agent-side by construction. Operator pushes from the host use the gate repo's own (empty) hooks dir and behave as before.

HOOKS_DIRNAME = '.terok-hooks' module-attribute

PUSH_MARKER_FILENAME = 'terok-push-marker' module-attribute

hooks_dir_for(mirror_root)

Return the sandbox-owned hooks directory for mirror_root.

Source code in src/terok_sandbox/gate/hooks.py
def hooks_dir_for(mirror_root: Path) -> Path:
    """Return the sandbox-owned hooks directory for *mirror_root*."""
    return mirror_root / HOOKS_DIRNAME

install_hooks(hooks_dir)

Idempotently render the hook scripts into hooks_dir.

Writes are atomic (tmp file + rename) because several per-container gate servers may share one mirror root and race here; content-equal installs are skipped so repeated server starts never churn mtimes.

Source code in src/terok_sandbox/gate/hooks.py
def install_hooks(hooks_dir: Path) -> None:
    """Idempotently render the hook scripts into *hooks_dir*.

    Writes are atomic (tmp file + rename) because several per-container
    gate servers may share one mirror root and race here; content-equal
    installs are skipped so repeated server starts never churn mtimes.
    """
    hooks_dir.mkdir(parents=True, exist_ok=True)
    # A shell trampoline accepts interpreter paths containing whitespace;
    # isolated Python ignores both the repository cwd and ambient PYTHONPATH.
    bootstrap = f"#!/bin/sh\n'''exec' {shlex.quote(sys.executable)} -I \"$0\" \"$@\"\n' '''\n"
    for name, content in (
        ("_host_tools.py", host_tools_source()),
        ("post-receive", bootstrap + _POST_RECEIVE),
    ):
        target = hooks_dir / name
        try:
            if target.read_text(encoding="utf-8") == content:
                continue
        except (FileNotFoundError, UnicodeDecodeError):
            pass
        # Per-invocation tmp name: concurrent installers must never rename
        # each other's half-written file out from under themselves.
        tmp = hooks_dir / f".{name}.{os.getpid()}.tmp"
        tmp.write_text(content, encoding="utf-8")
        tmp.chmod(0o755)
        tmp.replace(target)