← Files TrinityARCHIVED FILE

dist/hook-core.js

7.08 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

// Shared hook engine every dialect's thin entrypoint calls (see
// claude-hook.ts for the first one). Owns config/policy gating, race-safe
// turn-key correlation, the outbox append, and bounded-network draining. A
// dialect supplies only vendor-specific field extraction and event naming.
import { randomUUID } from "node:crypto";
import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadConfig, loadPolicy } from "./config.js";
import { isPolicyFresh, matchRoute, routeFor } from "./gate.js";
import { appendEvent, drain, INLINE_DRAIN_BUDGET_MS } from "./outbox.js";
import { gitRemoteOf, repoRelativeCwd, workspaceObserved } from "./observe.js";
import { refreshPolicy } from "./send.js";
function filterPayload(payload, allowed) {
    const out = {};
    for (const key of allowed) {
        if (key in payload)
            out[key] = payload[key];
    }
    return out;
}
function isRecord(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
// One write-once file per vendor turn id, never a shared map: two hook
// processes racing to mint the same vendor id can only ever collide on the
// SAME file's exclusive-create, and the loser reads the winner back rather
// than overwriting it. A shared JSON map under temp+rename would still lose
// updates when two processes each read-modify-write a distinct key at once.
// Hex encoding is injective and keeps hostile ids from becoming path segments.
function sanitizeTurnId(id) {
    return `id-${Buffer.from(id, "utf8").toString("hex") || "empty"}`;
}
export function turnKeyDir(dataDir, tool, sessionId) {
    // sessionId comes straight from untrusted hook stdin — join() does not
    // neutralize a "/" or ".." embedded inside one path segment, so it goes
    // through the same sanitizer as a vendor turn id before touching disk.
    return join(dataDir, "turnkeys", `${tool}-${sanitizeTurnId(sessionId)}`);
}
export function claimTurnKey(sessionDir, vendorTurnId) {
    const file = join(sessionDir, sanitizeTurnId(vendorTurnId));
    mkdirSync(sessionDir, { recursive: true });
    const minted = randomUUID();
    const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
    writeFileSync(temporary, minted, { flag: "wx", mode: 0o600 });
    try {
        linkSync(temporary, file);
        return minted;
    }
    catch (err) {
        if (err instanceof Error && "code" in err && err.code === "EEXIST") {
            return readFileSync(file, "utf8");
        }
        throw err;
    }
    finally {
        rmSync(temporary, { force: true });
    }
}
// latest is a separate one-line temp+rename file, consulted only by events
// whose dialect returns no vendor turn id — those are inherently
// order-ambiguous (no vendor identity to correlate on), so its benign race
// between two id-less events is documented, not hidden behind a lock.
function mintLatest(sessionDir) {
    mkdirSync(sessionDir, { recursive: true });
    const minted = randomUUID();
    const tmp = join(sessionDir, `.latest.${process.pid}.${randomUUID()}.tmp`);
    writeFileSync(tmp, minted);
    renameSync(tmp, join(sessionDir, "latest"));
    return minted;
}
function readLatest(sessionDir) {
    try {
        return readFileSync(join(sessionDir, "latest"), "utf8").trim() || undefined;
    }
    catch {
        return undefined; // no prompt observed yet (or state lost) — omit the hint
    }
}
function resolveTurnKey(dataDir, dialect, event, sessionId, payload) {
    const sessionDir = turnKeyDir(dataDir, dialect.tool, sessionId);
    try {
        const vendorTurnId = dialect.vendorTurnId(event, payload);
        if (vendorTurnId !== null && vendorTurnId !== "")
            return claimTurnKey(sessionDir, vendorTurnId);
        if (dialect.isPromptSubmit(event))
            return mintLatest(sessionDir);
        return readLatest(sessionDir);
    }
    catch {
        return undefined; // best-effort; the server falls back to open-turn-by-ordinal
    }
}
export async function runHook(dialect, event, stdin, env) {
    // Taken at hook entry, before any I/O: an inline drain's budget covers
    // this whole invocation, not just the time spent inside drain() itself.
    const hookEntryDeadline = Date.now() + INLINE_DRAIN_BUDGET_MS;
    const dataDir = dialect.dataDir(env);
    if (!dataDir)
        return; // dialect found no durable writable dir — never paired, or the host gave none
    const parsed = JSON.parse(stdin);
    if (!isRecord(parsed))
        return;
    const payload = parsed;
    if (dialect.suppress?.(dataDir, event, payload))
        return;
    const cfg = loadConfig(dataDir);
    if (!cfg)
        return; // never authorized — fail closed, zero network requests
    // Self-healing happens before the gate, not after: routeFor already
    // fails closed on a stale policy, so a refresh attempted only once
    // send:false has been decided can never run. Scoped to the dialect's own
    // session-start moment, not a literal event name — hook-core carries no
    // vendor vocabulary of its own.
    const cwd = dialect.cwd(event, payload) ?? process.cwd();
    const gitRemote = gitRemoteOf(cwd);
    let policy = loadPolicy(dataDir);
    if (!matchRoute(policy, gitRemote).send)
        return;
    if (dialect.isSessionStart(event) && !isPolicyFresh(policy, Date.now())) {
        const remaining = dialect.drainInline ? hookEntryDeadline - Date.now() : undefined;
        if (remaining === undefined || remaining > 0) {
            try {
                policy = await refreshPolicy(dataDir, cfg, remaining);
            }
            catch {
                policy = null;
            }
        }
    }
    const route = routeFor(policy, Date.now(), gitRemote);
    if (!route.send)
        return; // not allowlisted, or policy missing/still stale — no event, no drain
    const sessionId = dialect.sessionId(event, payload) ?? "";
    const repoCwd = repoRelativeCwd(cwd);
    const turnKey = sessionId === "" || dialect.isSessionStart(event)
        ? undefined
        : resolveTurnKey(dataDir, dialect, event, sessionId, payload);
    const captureEvent = {
        captureEventId: randomUUID(),
        tool: dialect.tool,
        kind: event,
        externalSessionId: sessionId,
        repo: route.canonicalRepo,
        repoCwd,
        occurredAt: new Date().toISOString(),
        ...(turnKey === undefined ? {} : { turnKey }),
        payload: filterPayload(payload, dialect.allow(event)),
    };
    appendEvent(dataDir, captureEvent);
    if (dialect.isSessionStart(event)) {
        const observed = workspaceObserved(cwd);
        if (observed) {
            appendEvent(dataDir, { ...observed, tool: dialect.tool, externalSessionId: sessionId, repo: route.canonicalRepo, repoCwd });
        }
    }
    // Whether THIS event drains at all is the dialect's call (a synchronous
    // dialect may only want its own lifecycle boundaries to drain); drainInline
    // above governs how a drain that does happen behaves.
    if (dialect.drainsOn(event)) {
        await drain(dataDir, cfg, { inline: dialect.drainInline, deadline: hookEntryDeadline }).catch(() => undefined);
    }
}

SHA-256: 3bdfba2488dfb42c00944aeb03759c6010ea0b60b74ebca18943e43b28ac32a8