/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>
72 lines
1.9 KiB
JavaScript
Executable File
72 lines
1.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* SessionStart hook: make sure the glance daemon is running, then record the event.
|
|
*
|
|
* This is the only hook that spawns anything. It always exits 0 — a monitoring
|
|
* dashboard must never be the reason a Grok Build session fails to start.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import {
|
|
PLUGIN_ROOT,
|
|
baseUrl,
|
|
envEnvelope,
|
|
glanceHome,
|
|
hookHeaders,
|
|
isDaemonUp,
|
|
postJson,
|
|
readConfig,
|
|
readStdinJson,
|
|
sleep,
|
|
} from "./glance-lib.mjs";
|
|
|
|
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
|
|
|
|
async function ensureDaemon(cfg) {
|
|
if (await isDaemonUp(cfg)) return true;
|
|
|
|
if (!fs.existsSync(SERVER_ENTRY)) {
|
|
// Not built yet. Say so once, on stderr, where it is recorded but harmless.
|
|
process.stderr.write(
|
|
`[grok-glance] not built yet - run \`npm install && npm run build\` in ${PLUGIN_ROOT}\n`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const home = glanceHome();
|
|
fs.mkdirSync(home, { recursive: true });
|
|
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
|
|
|
|
const child = spawn(process.execPath, [SERVER_ENTRY], {
|
|
detached: true,
|
|
stdio: ["ignore", logFd, logFd],
|
|
env: { ...process.env, GLANCE_STARTED_BY: "hook" },
|
|
});
|
|
child.unref();
|
|
|
|
// Give it a moment to bind before the first http hook fires.
|
|
for (let i = 0; i < 40; i++) {
|
|
await sleep(200);
|
|
if (await isDaemonUp(cfg, 300)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const payload = envEnvelope(await readStdinJson());
|
|
const cfg = readConfig();
|
|
|
|
try {
|
|
const up = await ensureDaemon(cfg);
|
|
if (up) {
|
|
// hookHeaders() is read here, not at import time: on a first-ever run the daemon we just
|
|
// spawned is what created hook.secret.
|
|
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500, hookHeaders());
|
|
}
|
|
} catch {
|
|
// Fail open, always.
|
|
}
|
|
|
|
process.exit(0);
|