Authenticate /hook/*, and make every hook a command hook

/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>
This commit is contained in:
iceBear67
2026-08-09 04:52:17 +00:00
co-authored by Claude Opus 5
parent b3b6bf3f70
commit 5eec1940be
15 changed files with 661 additions and 81 deletions
+59 -2
View File
@@ -8,6 +8,7 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
@@ -43,6 +44,62 @@ export function baseUrl(cfg = readConfig()) {
return `http://127.0.0.1:${cfg.port}`;
}
/** Header the daemon expects on /hook/*; kept in step with server/src/auth.ts. */
export const HOOK_HEADER = "x-glance-hook";
/**
* The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a
* regenerated secret is picked up without touching hooks.json.
*
* `create` is used by the build-time generator; the hook scripts pass false and simply get
* null when there is no secret yet. That is deliberate: a hook must never be the thing that
* creates state, and a missing secret has to degrade to "no telemetry", not "no tool call".
*/
export function hookSecret({ create = false } = {}) {
const file = path.join(glanceHome(), "hook.secret");
for (let attempt = 0; attempt < 2; attempt++) {
try {
const existing = fs.readFileSync(file, "utf8").trim();
if (existing) return existing;
} catch {
/* fall through */
}
if (!create) return null;
fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 });
const token = crypto.randomBytes(32).toString("base64url");
try {
// Exclusive: if the daemon created one a millisecond ago, read theirs instead.
fs.writeFileSync(file, token + "\n", { mode: 0o600, flag: "wx" });
return token;
} catch {
/* lost the race; loop re-reads */
}
}
try {
return fs.readFileSync(file, "utf8").trim() || null;
} catch {
return null;
}
}
export function hookHeaders() {
const token = hookSecret();
return token ? { [HOOK_HEADER]: token } : {};
}
/**
* How long the PreToolUse approval hook is allowed to run, in seconds.
*
* One formula, two consumers: scripts/gen-hooks.mjs writes it into hooks.json as the hook's
* `timeout`, and glance-approve.mjs derives its own wait from it. They must agree — if the
* script outlives its hook timeout, Grok Build kills it and the fail-open path never runs.
*/
export function approvalHookTimeoutSecs(cfg = readConfig()) {
const ms = Number(cfg.approval?.timeoutMs ?? 90_000);
const base = Number.isFinite(ms) && ms > 0 ? ms : 90_000;
return Math.ceil(base / 1000) + 35;
}
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
export async function readStdinJson() {
if (process.stdin.isTTY) return {};
@@ -77,10 +134,10 @@ export function envEnvelope(payload) {
};
}
export async function postJson(url, body, timeoutMs) {
export async function postJson(url, body, timeoutMs, extraHeaders = {}) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", ...extraHeaders },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});