/hook/record and /hook/approve accepted anything that reached the port. That is not "loopback only": `tailscale serve` proxies tailnet traffic to 127.0.0.1, so anyone who could reach the tunnel could forge timeline events and answer approval prompts. Both endpoints now require a 32-byte secret from $GLANCE_HOME/hook.secret (0600, created once, never rotated so nothing in flight is 403'd mid-session), compared in constant time before the body is read, as an x-glance-hook header or a ?k= parameter. Requests carrying x-forwarded-* are refused outright: a local hook process never sends them and a tunnelled caller always does. The check applies to /hook/* only, so the dashboard is unaffected. While wiring that up: the 13 passive `type: "http"` hooks could never have worked. Grok Build's http runner rejects every scheme but https, then resolves the host and blocks private/link-local/CGNAT addresses (validate_hook_url + is_blocked_ip), so neither loopback-over-http nor *.ts.net (100.64/10) can be a hook target - and it sends no header but Content-Type, so such a hook could not authenticate anyway. They were failing validation silently on every event. All of them are now command hooks running bin/glance-record.mjs, which costs a Node start and can present the secret. hooks.json is generated from hooks/hooks.template.json by scripts/gen-hooks.mjs (npm run build, glance sync-hooks). It creates the secret, derives the approval hook's timeout from approval.timeoutMs instead of hand-copying 125, and refuses to write a hook that cannot fire: bad type, non-positive timeout, non-https http URL, missing bin/ script, or a leftover placeholder. A template that embeds the token makes the output 0600 with a warning. Fail-open is unchanged: a missing, stale or rejected secret degrades to "no telemetry", and glance-approve.mjs still allows on every error path. glance status warns when the on-disk secret no longer matches the daemon's. Validated with the e2e suite (190 checks, including no-token/wrong-token/ same-length-token 403s, ?k= acceptance, x-forwarded-* refusal, and the recorder's fail-open paths) and a clean npm run build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.3 KiB
JavaScript
142 lines
5.3 KiB
JavaScript
#!/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.",
|
|
);
|
|
}
|