#!/usr/bin/env node /** * Generate hooks/hooks.json from hooks/hooks.template.json. * * Runs as part of `npm run build`, and again on `glance sync-hooks`. Two jobs: * * 1. Make sure the /hook/* shared secret exists (mode 0600, in $GLANCE_HOME). The daemon * requires it; without it the hook scripts are just anonymous POSTs, which is what this * whole mechanism exists to stop. * 2. Substitute the placeholders the template declares, so values that are really derived * from config.json - the approval hook's timeout above all - stop being hand-copied * constants that drift. * * It also refuses to emit a hook that cannot work. An `http` handler pointed at a non-https * URL is the specific mistake that made every passive hook in this plugin a no-op for a * while: Grok Build's http runner puts every URL through SSRF validation and rejects any * other scheme outright (xai-grok-hooks/src/runner/http.rs, `validate_hook_url`). */ import fs from "node:fs"; import path from "node:path"; import { PLUGIN_ROOT, approvalHookTimeoutSecs, hookSecret, readConfig } from "../bin/glance-lib.mjs"; const TEMPLATE = path.join(PLUGIN_ROOT, "hooks", "hooks.template.json"); const OUTPUT = path.join(PLUGIN_ROOT, "hooks", "hooks.json"); const cfg = readConfig(); const token = hookSecret({ create: true }); if (!token) { console.error(`gen-hooks: could not create the hook secret in ${process.env.GLANCE_HOME ?? "~/.grok/glance"}`); process.exit(1); } const timeoutSecs = approvalHookTimeoutSecs(cfg); let text = fs.readFileSync(TEMPLATE, "utf8"); // The quoted form first, so a JSON-valid template can carry a value that must end up numeric. text = text .split(`"{{APPROVAL_TIMEOUT_SECS}}"`) .join(String(timeoutSecs)) .split("{{APPROVAL_TIMEOUT_SECS}}") .join(String(timeoutSecs)); const embedsToken = text.includes("{{HOOK_TOKEN}}"); text = text.split("{{HOOK_TOKEN}}").join(token); // Trust the emitted bytes, not the placeholder: a template that mentions the placeholder in a // comment would otherwise ship the real secret in a world-readable file. (It did once.) const tokenIsInOutput = text.includes(token); if (tokenIsInOutput && !embedsToken) { console.error("gen-hooks: the hook secret leaked into hooks.json from somewhere unexpected"); process.exit(1); } const leftover = text.match(/\{\{[A-Z_]+\}\}/); if (leftover) { console.error(`gen-hooks: unknown placeholder ${leftover[0]} in hooks.template.json`); process.exit(1); } /* ------------------------------------------------------------------ validate */ let doc; try { doc = JSON.parse(text); } catch (err) { console.error(`gen-hooks: template did not produce valid JSON: ${err.message}`); process.exit(1); } const problems = []; let handlers = 0; for (const [event, groups] of Object.entries(doc.hooks ?? {})) { if (!Array.isArray(groups)) { problems.push(`${event}: expected an array of matcher groups`); continue; } for (const group of groups) { for (const h of group.hooks ?? []) { handlers++; const where = `${event} -> ${h.command ?? h.url ?? "(no target)"}`; if (h.type !== "command" && h.type !== "http") { problems.push(`${where}: type must be "command" or "http", got ${JSON.stringify(h.type)}`); continue; } if (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0) { problems.push(`${where}: timeout must be a positive number of seconds`); } if (h.type === "http") { // The runner rejects every scheme but https, and treats RFC1918 / CGNAT / link-local // targets as SSRF. That rules out both loopback-over-http and Tailscale's 100.64/10. if (!/^https:\/\//.test(h.url ?? "")) { problems.push( `${where}: http handlers must use an https:// URL - Grok Build's SSRF check ` + `rejects anything else, so this hook would never fire`, ); } } else { const script = /bin\/([A-Za-z0-9._-]+)/.exec(h.command ?? ""); if (!script) { problems.push(`${where}: could not tell which script this command runs`); } else if (!fs.existsSync(path.join(PLUGIN_ROOT, "bin", script[1]))) { problems.push(`${where}: bin/${script[1]} does not exist`); } } } } } if (problems.length) { console.error("gen-hooks: refusing to write hooks.json\n"); for (const p of problems) console.error(` - ${p}`); process.exit(1); } /* --------------------------------------------------------------------- write */ const mode = tokenIsInOutput ? 0o600 : 0o644; const previous = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, "utf8") : null; if (previous === text) { // Leave the mtime alone: a no-op build should not look like a change. fs.chmodSync(OUTPUT, mode); console.log(`hooks.json already current (${handlers} handlers, approval timeout ${timeoutSecs}s)`); } else { fs.writeFileSync(OUTPUT, text, { mode }); fs.chmodSync(OUTPUT, mode); console.log( `wrote hooks/hooks.json - ${handlers} handlers, approval timeout ${timeoutSecs}s` + (previous === null ? " (new file)" : ""), ); } if (tokenIsInOutput) { console.warn( "gen-hooks: hooks.json now contains the hook secret (via the HOOK_TOKEN placeholder); it is " + "mode 0600 and must not be committed.", ); }