Model Citizen by Jake Selby v0.13.1
GitHub
HookStop

stop-gate

Stop hook: run the repository's own gate and refuse to finish while it is red.

Event
Stop
Runs
always
Dispatcher
adapters/claude-code/hook.py
Script
policy/hooks/stop-gate.py

What it does

Opt-in per repository — the gate is the fenced block under the ## Gate heading of the repo's AGENTS.md, executed together in one shell. A repo without that block is untouched. Trusted folders only: the block is a repository's own text, so it runs only where Claude Code's folder-trust dialog has been accepted (the hasTrustDialogAccepted flag it records per project), the same consent that gates a repository's .claude/settings.json hooks, or where the root is listed in ~/.config/agent-harness/trusted.txt by harness trust. Bounded: after MAX_BLOCKS consecutive blocks the turn is released, so a gate that can never pass cannot trap a session. The count is kept per session, so two sessions stopping in the same checkout never reset each other's; a session silent for STALE_SECONDS is forgotten. A timeout releases the turn as unverified; unexpected errors block. Neither records success.

When it runs

The policy answers on Stop. It runs in every install. It has no settings entry of its own: harness sync registers one dispatcher command per event, adapters/claude-code/hook.py, marked harness:runtime-stop in your settings.json. That dispatcher routes each call to the policies its event and tool need and composes their answers; stop-gate is the policy's id in OWNERSHIP.json.

The script

policy/hooks/stop-gate.pypython
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Stop hook: run the repository's own gate and refuse to finish while it is red.

Opt-in per repository — the gate is the fenced block under the `## Gate` heading of the
repo's `AGENTS.md`, executed together in one shell. A repo without that block is untouched.
Trusted folders only: the block is a repository's own text, so it runs only where Claude
Code's folder-trust dialog has been accepted (the `hasTrustDialogAccepted` flag it records
per project), the same consent that gates a repository's `.claude/settings.json` hooks, or
where the root is listed in ~/.config/agent-harness/trusted.txt by `harness trust`.
Bounded: after MAX_BLOCKS consecutive blocks the turn is released, so a gate that can never
pass cannot trap a session. The count is kept per session, so two sessions stopping in the same
checkout never reset each other's; a session silent for STALE_SECONDS is forgotten. A timeout releases the turn as unverified; unexpected errors block. Neither records success.
"""
import hashlib
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path

MAX_BLOCKS = 8
STALE_SECONDS = 24 * 3600
BUDGET_SECONDS = 240
TAIL_LINES = 30
GATE_FILES = ("AGENTS.md", "CLAUDE.md")
STATE = Path.home() / ".local" / "state" / "agent-harness" / "stop-gate"
TRUSTED = Path.home() / ".config" / "agent-harness" / "trusted.txt"


_LOG = []


def decisions():
    """The sibling decision log, or None. A log that will not load costs nothing but its rows."""
    if not _LOG:
        module = None
        try:
            path = Path(__file__).resolve().parent / "decisions.py"
            spec = importlib.util.spec_from_file_location("harness_decisions", str(path))
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)
        except Exception:
            module = None
        _LOG.append(module)
    return _LOG[0]


def log_gate(payload, root, commands, answer, outcome):
    """Record what this Stop event was answered with, and how the gate turned out.

    Both records are written here because both facts are known here: the hook runs the gate
    itself, so the outcome does not wait for a later event. The judged input is the repository's
    own gate block — the text this hook decided to run — and never the turn's final message.

    The claim the turn ended on is the transcript's, not the payload's: a Stop event carries no
    assistant text, so the log reads a capped tail of the file the event names. That read only
    happens under `telemetry.completion_claim`, which is off, because it is the one field in the
    log that holds model prose. Both runtimes' names for the file are accepted.
    """
    module = decisions()
    if module is None:
        return
    payload = payload if isinstance(payload, dict) else {}
    text = str(root) + "\n" + "\n".join(commands)
    transcript = (payload.get("transcript_path") or payload.get("rollout_path")
                  or payload.get("session_path") or "")
    identity = module.record("stop-gate", answer, text, payload, transcript=transcript)
    if identity and outcome is not None:
        module.observe(identity, outcome, "stop-gate", payload.get("session_id") or "")


def git(root, *args):
    try:
        out = subprocess.run(["git", "-C", root, *args],
                             capture_output=True, text=True, timeout=10)
    except Exception:
        return ""
    return out.stdout if out.returncode == 0 else ""


def git_root(cwd):
    root = git(cwd, "rev-parse", "--show-toplevel").strip()
    return root or None


def claude_config():
    """Claude Code's per-user state file, honouring CLAUDE_CONFIG_DIR."""
    config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
    return (Path(config_dir) if config_dir else Path.home()) / ".claude.json"


def listed_roots():
    """Roots recorded by `harness trust`, as written and resolved."""
    try:
        lines = TRUSTED.read_text(encoding="utf-8").splitlines()
    except OSError:
        return set()
    roots = set()
    for ln in lines:
        ln = ln.strip()
        if ln and not ln.startswith("#"):
            roots.update((ln, str(Path(ln).resolve())))
    return roots


def trusted(root, cwd):
    """True when the folder-trust dialog has been accepted for the working directory, the
    repository root, or a directory between them, or when `harness trust` listed the root."""
    if os.environ.get("HARNESS_RUNTIME") == "codex":
        return bool(listed_roots() & {str(Path(root)), str(Path(root).resolve())})
    try:
        projects = json.loads(claude_config().read_text(encoding="utf-8")).get("projects") or {}
    except Exception:
        projects = {}
    if not isinstance(projects, dict):
        projects = {}
    top = Path(root).resolve()
    if listed_roots() & {str(Path(root)), str(top)}:
        return True
    keys = {str(Path(root)), str(top)}
    path = Path(cwd)
    while path.resolve() == top or top in path.resolve().parents:
        keys.update((str(path), str(path.resolve())))  # symlinked temp dirs record either form
        if path.resolve() == top:
            break
        path = path.parent
    return any(isinstance(projects.get(k), dict) and projects[k].get("hasTrustDialogAccepted") is True
               for k in keys)


def gate_file(root):
    for name in GATE_FILES:
        path = Path(root) / name
        if path.is_file():
            return path
    return None


def gate_commands(root):
    path = gate_file(root)
    if path is None:
        return []
    try:
        lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    except OSError:
        return []
    start = None
    for i, raw in enumerate(lines):
        if raw.strip().lower() == "## gate":
            start = i + 1
            break
    if start is None:
        return []
    commands = []
    fenced = False
    for raw in lines[start:]:
        text = raw.strip()
        if text.startswith("```"):
            if fenced:
                break
            fenced = True
        elif not fenced:
            if text.startswith("#"):  # the next heading, with no block between
                break
        elif text and not text.startswith("#"):
            commands.append(text)
    return commands


def tree_hash(root):
    digest = hashlib.sha256()
    digest.update(("gate-v2:" + str(Path(root).resolve())).encode())
    def checked(*args):
        return subprocess.run(["git", "-C", root, *args], capture_output=True,
                              check=True, timeout=10).stdout
    for args in (("rev-parse", "HEAD"), ("status", "--porcelain", "-z"),
                 ("diff", "--binary"), ("diff", "--cached", "--binary")):
        digest.update(hashlib.sha256(checked(*args)).digest())
    for name in checked("ls-files", "--others", "--exclude-standard", "-z").split(b"\0"):
        if not name:
            continue
        path = Path(root) / os.fsdecode(name)
        digest.update(name + b"\0")
        if path.is_symlink():
            digest.update(os.fsencode(os.readlink(path)))
        elif path.is_file():
            with path.open("rb") as stream:
                for chunk in iter(lambda: stream.read(1024 * 1024), b""):
                    digest.update(chunk)
    digest.update("\n".join(gate_commands(root)).encode())
    digest.update(str(BUDGET_SECONDS).encode())
    return digest.hexdigest()


def state_path(root):
    return STATE / (hashlib.sha256(root.encode("utf-8")).hexdigest() + ".json")


def read_state(path):
    try:
        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def write_state(path, data):
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temporary = tempfile.mkstemp(prefix=".gate-", dir=str(path.parent))
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            json.dump(data, fh)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


def run_gate(root, commands):
    """The first red command as (command, exit code, output), or None when every one passes."""
    cmd = "\n".join(commands)
    out = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", cmd], cwd=root,
                         capture_output=True, text=True, timeout=BUDGET_SECONDS)
    if out.returncode != 0:
        return cmd, out.returncode, (out.stdout or "") + (out.stderr or "")
    return None


def reason(path, cmd, code, output):
    tail = "\n".join(output.splitlines()[-TAIL_LINES:]).strip()
    return (
        f"The gate in {path.name} is red: `{cmd}` exited {code}.\n\n"
        f"{tail}\n\n"
        "That command is the check block this repository defines under `## Gate`, run at the end "
        "of a turn once files have changed. Fix it and finish, or say why it cannot pass."
    )


def live_sessions(state, now):
    """The per-session block counts in `state`, without entries silent for STALE_SECONDS."""
    sessions = state.get("sessions")
    if not isinstance(sessions, dict):
        return {}
    kept = {}
    for session, entry in sessions.items():
        try:
            blocks, seen = int(entry["blocks"]), float(entry["seen"])
        except (KeyError, TypeError, ValueError):
            continue
        if now - seen < STALE_SECONDS:
            kept[session] = {"blocks": blocks, "seen": seen}
    return kept


def release(path, session, note):
    # Re-read: the gate can run for minutes, and another session may have recorded blocks meanwhile.
    sessions = live_sessions(read_state(path), time.time())
    sessions.pop(session, None)
    write_state(path, {"green_hash": None, "status": "unverified", "reason": note,
                       "sessions": sessions})
    sys.stderr.write("stop-gate: " + note + "\n")


def main():
    try:
        payload = json.load(sys.stdin)
    except Exception:
        return
    if not isinstance(payload, dict):
        return
    cwd = payload.get("cwd") or os.getcwd()
    root = git_root(cwd)
    if not root:
        return
    commands = gate_commands(root)
    if not commands:
        return
    if not trusted(root, cwd):
        sys.stderr.write("stop-gate: folder not trusted in Claude Code and not listed by "
                         "`harness trust`; gate skipped. Run `harness trust .` in this folder to "
                         "let it run the repository's own checks.\n")
        log_gate(payload, root, commands, "skipped", "untrusted")
        return

    current = tree_hash(root)
    path = state_path(root)
    state = read_state(path)
    if state.get("green_hash") == current:
        log_gate(payload, root, commands, "skipped", "passed")
        return

    session = payload.get("session_id") or ""
    try:
        failure = run_gate(root, commands)
    except subprocess.TimeoutExpired:
        release(path, session, f"gate ran past {BUDGET_SECONDS}s; letting the turn end")
        log_gate(payload, root, commands, "released", "timeout")
        return
    if failure is None:
        if tree_hash(root) != current:
            release(path, session, "working tree changed during the gate; result unverified")
            log_gate(payload, root, commands, "released", "unverified")
            return
        write_state(path, {"green_hash": current, "status": "passed", "sessions": {}})
        log_gate(payload, root, commands, "released", "passed")
        return

    now = time.time()
    sessions = live_sessions(read_state(path), now)
    blocks = sessions.get(session, {}).get("blocks", 0) + 1
    if blocks >= MAX_BLOCKS:
        release(path, session, f"released after {MAX_BLOCKS} blocks; gate still red")
        log_gate(payload, root, commands, "released", "failed")
        return
    sessions[session] = {"blocks": blocks, "seen": now}
    write_state(path, {"green_hash": None, "status": "failed", "sessions": sessions})
    log_gate(payload, root, commands, "blocked", "failed")
    cmd, code, output = failure
    print(json.dumps({"decision": "block", "reason": reason(gate_file(root), cmd, code, output)}))


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(json.dumps({"decision": "block", "reason": "Gate is unverified: " + str(exc)}))
↑↓ move↵ openPagefind · indexed at build