UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

238 lines (237 loc) • 11.2 kB
import { createHash } from "node:crypto"; import { lstat, readFile, realpath } from "node:fs/promises"; import path from "node:path"; import { codexHome } from "../home-paths.js"; import { logger } from "../logger.js"; import { pretrustEnabled } from "./folder-trust.js"; import { atomic, object, objects } from "./protocol.js"; /** * Keeping the owner's trust in Phren's own Codex hooks when an install * rewrites them. * * Codex 0.158+ runs a `hooks.json` handler only when `config.toml` holds a * matching `trusted_hash` for it, under * `[hooks.state."<hooks.json path>:<event_snake>:<group>:<handler>"]`. The * hash covers the handler's command, timeout and matcher, so an install that * changes Phren's timeout (3 s to 15 s) or moves its group turns a handler the * owner trusted into a "modified" one: Codex skips it and every new TUI opens * on "Hooks need review". * * The rule never grants trust the owner did not give. A Phren handler whose * key has no matching hash is trusted only when a hash stored for the same * event in the same hooks.json is one of: * * - the same command with a timeout Phren has shipped (3, 10, 15, 60 or none); * - a Phren handler for that event in hooks.json as it was before this * install rewrote it (a changed node path, a moved group). * * A hash cannot come from anything but that event and that command, so a * match means the owner trusted Phren's handler there. Other handlers are * never touched. `PHREN_PRETRUST=off` turns this off with folder trust. */ /** Timeouts Phren's Codex callbacks have carried in any release; undefined is "no timeout key". */ const SHIPPED_TIMEOUTS = [3, 10, 15, 60, undefined]; const quote = (s) => "'" + s.replace(/'/g, "'\\''") + "'"; /** `SessionStart` to `session_start`, the event name in Codex's trust keys. */ export function codexEventName(event) { return event.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); } /** JSON with every object's keys sorted and no whitespace: serde_json's output for Codex's identity value. */ function canonicalJson(value) { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (value && typeof value === "object") { const record = value; return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; } // JSON.stringify escapes strings as serde_json does: `"`, `\` and control // characters only, lowercase \u00xx, non-ASCII left as UTF-8. return JSON.stringify(value); } /** * Codex's `hook_hash` for one handler in one group (codex-rs hooks * discovery + config fingerprint): sha256 of the canonical JSON of * `{ event_name, matcher?, hooks: [handler] }`, where the handler always * carries `timeout` (default 600, at least 1) and `async` (default false). */ export function codexHookHash(event, group, handler) { const name = codexEventName(event); const shortLived = name === "session_end" || name === "interrupt"; const given = typeof handler.timeout === "number" ? handler.timeout : undefined; const timeout = shortLived ? Math.min(3, Math.max(1, given ?? 1)) : Math.max(1, given ?? 600); const hook = { type: handler.type ?? "command", command: handler.command, timeout, async: handler.async ?? false }; for (const key of ["statusMessage", "commandWindows", "additionalContextLimit"]) { if (handler[key] !== undefined && handler[key] !== null) hook[key] = handler[key]; } const identity = { event_name: name, hooks: [hook] }; if (typeof group.matcher === "string") identity.matcher = group.matcher; return `sha256:${createHash("sha256").update(canonicalJson(identity), "utf8").digest("hex")}`; } /** Phren's own handlers in a Codex hooks.json text; none when it does not parse. */ function ownHandlers(text, program) { if (text === undefined) return []; let parsed; try { parsed = JSON.parse(text); } catch { return []; } const suffix = ` ${quote(program)} hook codex`; const found = []; for (const [event, groups] of Object.entries(object(object(parsed).hooks))) { objects(groups).forEach((group, groupIndex) => objects(group.hooks).forEach((handler, handlerIndex) => { if (typeof handler.command === "string" && handler.command.endsWith(suffix)) found.push({ event, group, handler, groupIndex, handlerIndex }); })); } return found; } const TABLE = /^[ \t]*\[[ \t]*hooks[ \t]*\.[ \t]*state[ \t]*\.[ \t]*("(?:[^"\\\r\n]|\\.)*"|'[^'\r\n]*')[ \t]*\][ \t]*(?:#.*)?$/; const HASH_LINE = /^[ \t]*trusted_hash[ \t]*=[ \t]*"([^"\r\n]*)"/; /** The `[hooks.state."<key>"]` tables of a config.toml, by line. */ function stateTables(lines) { const tables = []; let current; lines.forEach((line, index) => { if (/^[ \t]*\[/.test(line)) { if (current) current.end = index; current = undefined; const match = TABLE.exec(line); if (!match) return; let key; try { key = match[1].startsWith("'") ? match[1].slice(1, -1) : JSON.parse(match[1]); } catch { return; } current = { key, header: index, end: lines.length }; tables.push(current); return; } const hash = current && current.hash === undefined ? HASH_LINE.exec(line) : null; if (current && hash) { current.hash = hash[1]; current.hashLine = index; } }); return tables; } function tomlKey(value) { return `"${value.replace(/[\\"\u0000-\u001f\u007f]/g, c => c === "\\" ? "\\\\" : c === "\"" ? "\\\"" : `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)}"`; } /** * The config text with trust carried forward to Phren's handlers in * `hooks` (the hooks.json text as it is now), or undefined when nothing * changes. `before` is hooks.json as it stood before this install rewrote * it, when there was a rewrite. `hooksPaths` are the names Codex may key * this hooks.json by; the first is used for a new key. */ export function codexHookTrustText(config, hooks, program, hooksPaths, before) { const own = ownHandlers(hooks, program); if (!own.length) return undefined; const newline = config.includes("\r\n") ? "\r\n" : "\n"; const lines = config.split(/\r?\n/); const tables = stateTables(lines); const split = (key) => { const match = /^(.*):([a-z_]+):(\d+):(\d+)$/.exec(key); return match && hooksPaths.includes(match[1]) ? { file: match[1], event: match[2] } : undefined; }; const stored = tables.flatMap(table => { const parts = split(table.key); return parts && table.hash ? [{ ...parts, hash: table.hash }] : []; }); if (!stored.length) return undefined; const keyPath = stored[0].file; const previous = ownHandlers(before, program); const edits = new Map(), appended = [], carried = []; for (const entry of own) { const name = codexEventName(entry.event); const key = `${keyPath}:${name}:${entry.groupIndex}:${entry.handlerIndex}`; const want = codexHookHash(entry.event, entry.group, entry.handler); const table = tables.find(t => t.key === key); if (table?.hash === want) continue; const accepted = new Set([ ...SHIPPED_TIMEOUTS.map(timeout => codexHookHash(entry.event, entry.group, { ...entry.handler, timeout })), ...previous.filter(p => p.event === entry.event).map(p => codexHookHash(p.event, p.group, p.handler)), ]); if (!stored.some(s => s.event === name && accepted.has(s.hash))) continue; const line = `trusted_hash = "${want}"`; if (table?.hashLine !== undefined) edits.set(table.hashLine, lines[table.hashLine].replace(/"[^"]*"/, `"${want}"`)); else if (table) edits.set(table.header, `${lines[table.header]}${newline}${line}`); else appended.push(`[hooks.state.${tomlKey(key)}]${newline}${line}${newline}`); carried.push(key); } if (!carried.length) return undefined; const next = lines.map((line, index) => edits.get(index) ?? line); if (appended.length) { // New tables go right after the last hooks.state table, next to their siblings. const last = tables[tables.length - 1]; const insertAt = last.end; const block = appended.join(newline); if (insertAt < next.length) next.splice(insertAt, 0, ...block.split(newline).slice(0, -1), ""); else { const text = next.join(newline); const separator = text.endsWith(newline + newline) || text === "" ? "" : text.endsWith(newline) ? newline : newline + newline; return { text: text + separator + block, carried }; } } return { text: next.join(newline), carried }; } /** * Carries the owner's Codex trust forward to Phren's handlers in * `$CODEX_HOME/hooks.json`. Runs after every install, update, rollback and * module reconcile, whether or not hooks.json changed, so a computer an * earlier install left untrusted is repaired too. Never throws. */ export async function carryCodexHookTrust(program, before, env = process.env) { if (!pretrustEnabled(env)) return []; try { const directory = codexHome(env); const hooksFile = path.join(directory, "hooks.json"); const hooks = await readFile(hooksFile, "utf8").catch(() => undefined); if (hooks === undefined) return []; const hooksPaths = [...new Set([hooksFile, await realpath(hooksFile).catch(() => hooksFile)])]; const configured = path.join(directory, "config.toml"); const file = await realpath(configured).catch(() => configured); for (let attempt = 0; attempt < 3; attempt++) { const text = await readFile(file, "utf8").catch(error => { if (error.code === "ENOENT") return undefined; throw error; }); // No config means the owner never trusted anything. if (text === undefined) return []; const result = codexHookTrustText(text, hooks, program, hooksPaths, before); if (!result) return []; const metadata = await lstat(file); // Codex may have rewritten the file meanwhile; start over from its text. if (await readFile(file, "utf8").catch(() => "") !== text) continue; await atomic(file, result.text, metadata.mode & 0o777); logger.info("install", `Kept Codex trust for Phren's rewritten hooks: ${result.carried.join(", ")}.`); return result.carried; } throw new Error("config.toml kept changing while it was being updated"); } catch (error) { logger.warn("install", `Could not carry Codex hook trust forward: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`); return []; } }