Model Citizen by Jake Selby v0.15.0
GitHub
HookPreToolUse

stage-files

PreToolUse hook: copy each file `SendUserFile` names from outside the session's working directory into it, so a Remote Control client can open the file.

Event
PreToolUse
Runs
always
Dispatcher
adapters/claude-code/hook.py
Script
policy/hooks/stage-user-files.py

What it does

In a Remote Control session the tool uploads nothing. The app keeps the path and asks the session for the file when you open it, and Claude Code serves it only when the file's real path is under the directory the session started in or a directory added to it. Agents write reports, renders and screenshots to temp directories, scratch directories and other worktrees, so without this the app refuses most of what they send with "Couldn't load this file".

A file whose real path is outside the hook's working directory, which Claude Code keeps inside the session's working directories, is copied to .agent-harness/outbox/<digest>/<name> there, and the path is rewritten to the copy. The digest covers the source's real path, size and modification and change times, so sending an unchanged file again reuses its copy and any rewrite, even one that restores the modification time, gets a new one. The outbox carries a .gitignore that ignores everything in it, so in a repository a copy never shows in git status, a commit or the lint. Directories untouched for KEEP_DAYS are removed when something new is staged, and harness task leaves the outbox out of its fingerprint.

A file already inside, a missing path and a directory are left alone; the tool reports its own error for a path it cannot send. A file past the call's byte or time budget, or one that cannot be copied, is left alone with a notice. This hook never denies: on any failure the call runs unchanged.

Each call that stages or keeps a file is one stage-user-files row in the decision log: staged, kept or staged+kept, with the counts and the bytes copied, and the file names (never their paths) as its input.

Test: echo '{"tool_name":"SendUserFile","cwd":"'"$PWD"'","tool_input":{"files":["/etc/hosts"]}}' | python3 stage-user-files.py

When it runs

The policy answers on PreToolUse. 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-pretooluse in your settings.json. That dispatcher routes each call to the policies its event and tool need and composes their answers; stage-files is the policy's id in OWNERSHIP.json.

The script

policy/hooks/stage-user-files.pypython
#!/usr/bin/env python3
"""PreToolUse hook: copy each file `SendUserFile` names from outside the session's working
directory into it, so a Remote Control client can open the file.

In a Remote Control session the tool uploads nothing. The app keeps the path and asks the session
for the file when you open it, and Claude Code serves it only when the file's real path is under
the directory the session started in or a directory added to it. Agents write reports, renders
and screenshots to temp directories, scratch directories and other worktrees, so without this the
app refuses most of what they send with "Couldn't load this file".

A file whose real path is outside the hook's working directory, which Claude Code keeps inside
the session's working directories, is copied to `.agent-harness/outbox/<digest>/<name>` there,
and the path is rewritten to the copy. The digest covers the source's real path, size and
modification and change times, so sending an unchanged file again reuses its copy and any rewrite,
even one that restores the modification time, gets a new one. The outbox carries a `.gitignore` that ignores everything in it, so in a repository a
copy never shows in `git status`, a commit or the lint. Directories untouched for KEEP_DAYS are
removed when something new is staged, and `harness task` leaves the outbox out of its fingerprint.

A file already inside, a missing path and a directory are left alone; the tool reports its own
error for a path it cannot send. A file past the call's byte or time budget, or one that cannot be
copied, is left alone with a notice. This hook never denies: on any failure the call runs unchanged.

Each call that stages or keeps a file is one `stage-user-files` row in the decision log:
`staged`, `kept` or `staged+kept`, with the counts and the bytes copied, and the file names
(never their paths) as its input.

Test: echo '{"tool_name":"SendUserFile","cwd":"'"$PWD"'","tool_input":{"files":["/etc/hosts"]}}' | python3 stage-user-files.py
"""
import hashlib
import importlib.util
import json
import os
import re
import shutil
import sys
import tempfile
import time
from pathlib import Path

OUTBOX = Path(".agent-harness") / "outbox"
# What one call may copy, in bytes and in seconds, against the coordinator's ten-second
# PreToolUse budget. Time is checked between files, so one slow source can still outrun it; the
# hook is then stopped and the call goes through unchanged.
MAX_BYTES = 64 * 1024 * 1024
DEADLINE_SECONDS = 4
KEEP_DAYS = 14
DIGEST = re.compile(r"[0-9a-f]{16}")


def log_decision(answer, text, payload, fields=None):
    """One `stage-user-files` row in the decision log (`decisions.py`). Never raises."""
    try:
        location = Path(os.path.realpath(__file__)).parent / "decisions.py"
        spec = importlib.util.spec_from_file_location("harness_stage_user_files_decisions", str(location))
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        module.record("stage-user-files", answer, text, payload if isinstance(payload, dict) else {},
                      fields=fields)
    except Exception:
        pass


def real(path):
    return Path(os.path.realpath(str(path)))


def outbox(root):
    """The outbox under `root`, created on first use; None when any part of it is a link."""
    box = root / OUTBOX
    ignore = box / ".gitignore"
    if any(p.is_symlink() for p in (root / OUTBOX.parent, box, ignore)):
        return None
    box.mkdir(parents=True, exist_ok=True)
    if not real(box).is_relative_to(real(root)):
        return None
    if not ignore.exists():
        ignore.write_text("*\n", encoding="utf-8")
    return box


def stage(source, info, box):
    """Copy `source` into `box`, or reuse its earlier copy. Returns `(target, bytes copied)`."""
    key = "%s\0%d\0%d\0%d" % (source, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
    folder = box / hashlib.sha256(key.encode("utf-8", "surrogateescape")).hexdigest()[:16]
    target = folder / source.name
    if folder.is_symlink() or target.is_symlink():
        raise OSError("outbox entry is a link: " + folder.name)
    if target.is_file() and target.stat().st_size == info.st_size:
        os.utime(str(folder))
        return target, 0
    folder.mkdir(exist_ok=True)
    handle, partial = tempfile.mkstemp(dir=str(folder), prefix=".", suffix=".part")
    os.close(handle)
    try:
        shutil.copyfile(str(source), partial)
        os.replace(partial, str(target))
    except BaseException:
        try:
            os.unlink(partial)
        except OSError:
            pass
        raise
    return target, info.st_size


def prune(box, now):
    cutoff = now - KEEP_DAYS * 86400
    for entry in box.iterdir():
        try:
            if (DIGEST.fullmatch(entry.name) and not entry.is_symlink() and entry.is_dir()
                    and entry.stat().st_mtime < cutoff):
                shutil.rmtree(str(entry), ignore_errors=True)
        except OSError:
            continue


def decide(payload):
    """The hook output for one PreToolUse payload, or None to leave the call as it is."""
    if not isinstance(payload, dict) or payload.get("tool_name") != "SendUserFile":
        return None
    inputs = payload.get("tool_input")
    cwd = payload.get("cwd")
    if not isinstance(inputs, dict) or not isinstance(cwd, str) or not os.path.isabs(cwd):
        return None
    files = inputs.get("files")
    if not isinstance(files, list) or not os.path.isdir(cwd):
        return None
    root, inside = Path(cwd), real(cwd)
    deadline, budget = time.monotonic() + DEADLINE_SECONDS, MAX_BYTES
    box, sent, staged, kept, copied = None, [], 0, [], 0
    for entry in files:
        sent.append(entry)
        if not isinstance(entry, str) or not entry.strip():
            continue
        try:
            path = Path(os.path.expanduser(entry))
            source = real(path if path.is_absolute() else root / path)
            if source.is_relative_to(inside) or not source.is_file():
                continue
            info = source.stat()
            if info.st_size > budget or time.monotonic() > deadline:
                kept.append(source.name)
                continue
            box = box or outbox(root)
            if box is None:
                kept.append(source.name)
                continue
            target, wrote = stage(source, info, box)
            sent[-1] = str(target)
            budget -= info.st_size
            copied += wrote
            staged += 1
        except (OSError, ValueError):
            kept.append(os.path.basename(entry.rstrip("/")) or entry)
    if staged:
        try:
            prune(box, time.time())
        except OSError:
            pass
    notes = []
    if staged:
        notes.append("copied %d file(s) from outside the session's working directory into %s/, "
                     "so Remote Control can open them" % (staged, OUTBOX.as_posix()))
    if kept:
        notes.append("could not copy %s into the working directory, so Remote Control may not open it"
                     % ", ".join(kept))
    if not notes:
        return None
    answer = "+".join(name for name, count in (("staged", staged), ("kept", len(kept))) if count)
    log_decision(answer, ", ".join(os.path.basename(str(s)) for s in sent if isinstance(s, str)),
                 payload, {"staged": staged, "kept": len(kept), "files": len(files),
                           "bytes_staged": copied})
    result = {"systemMessage": "stage-user-files: " + "; ".join(notes) + "."}
    if staged:
        result["hookSpecificOutput"] = {"hookEventName": "PreToolUse",
                                        "updatedInput": dict(inputs, files=sent)}
    return result


def main():
    try:
        result = decide(json.load(sys.stdin))
    except Exception:
        return
    if result:
        print(json.dumps(result))


if __name__ == "__main__":
    main()
↑↓ move↵ openPagefind · indexed at build