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:
co-authored by
Claude Opus 5
parent
b3b6bf3f70
commit
5eec1940be
+20
-17
@@ -10,7 +10,7 @@
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
PLUGIN_ROOT,
|
||||
baseUrl,
|
||||
@@ -124,6 +124,12 @@ switch (cmd) {
|
||||
console.log(` watchers : ${s.watchers}`);
|
||||
console.log(` sessions : ${s.sessions}`);
|
||||
console.log(` events kept : ${s.events}`);
|
||||
if (s.hookAuthOk === false) {
|
||||
console.log(
|
||||
"\n ! hook auth mismatch: $GLANCE_HOME/hook.secret no longer matches what the daemon" +
|
||||
"\n loaded, so events are being dropped. Restart it: glance stop && glance up",
|
||||
);
|
||||
}
|
||||
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
|
||||
break;
|
||||
}
|
||||
@@ -200,22 +206,19 @@ switch (cmd) {
|
||||
}
|
||||
|
||||
case "sync-hooks": {
|
||||
const file = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
|
||||
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
let changed = 0;
|
||||
for (const groups of Object.values(doc.hooks ?? {})) {
|
||||
for (const group of groups) {
|
||||
for (const h of group.hooks ?? []) {
|
||||
if (h.type === "http" && typeof h.url === "string") {
|
||||
const next = h.url.replace(/127\.0\.0\.1:\d+/, `127.0.0.1:${cfg.port}`);
|
||||
if (next !== h.url) changed++;
|
||||
h.url = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
// hooks.json is generated, not edited: regenerate it from hooks/hooks.template.json.
|
||||
// The port is not baked into it any more — the hook scripts read config.json themselves —
|
||||
// so the thing this actually refreshes is the approval gate's timeout, plus the hook
|
||||
// secret if it has gone missing.
|
||||
const { status } = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(PLUGIN_ROOT, "scripts", "gen-hooks.mjs")],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (status !== 0) process.exit(status ?? 1);
|
||||
if (await isDaemonUp(cfg)) {
|
||||
console.log("restart the daemon to pick up config changes: glance stop && glance up");
|
||||
}
|
||||
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
||||
console.log(`rewrote ${changed} hook url(s) to port ${cfg.port}`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -241,7 +244,7 @@ switch (cmd) {
|
||||
glance devices list enrolled devices
|
||||
glance revoke <id-prefix> revoke a device
|
||||
glance approval <off|risky|all> remote approval policy
|
||||
glance sync-hooks rewrite hook urls after a port change
|
||||
glance sync-hooks regenerate hooks/hooks.json from the template
|
||||
glance logs tail the daemon log
|
||||
`);
|
||||
}
|
||||
|
||||
+21
-4
@@ -10,7 +10,15 @@
|
||||
* call Grok was already about to make. This hook can never introduce a new command.
|
||||
*/
|
||||
|
||||
import { baseUrl, envEnvelope, postJson, readConfig, readStdinJson } from "./glance-lib.mjs";
|
||||
import {
|
||||
approvalHookTimeoutSecs,
|
||||
baseUrl,
|
||||
envEnvelope,
|
||||
hookHeaders,
|
||||
postJson,
|
||||
readConfig,
|
||||
readStdinJson,
|
||||
} from "./glance-lib.mjs";
|
||||
|
||||
function allow() {
|
||||
process.exit(0);
|
||||
@@ -28,11 +36,20 @@ function deny(reason) {
|
||||
const payload = envEnvelope(await readStdinJson());
|
||||
const cfg = readConfig();
|
||||
|
||||
// Stay inside the hook timeout declared in hooks/hooks.json (125s).
|
||||
const waitMs = Math.min(115_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000);
|
||||
// Finish inside the hook timeout that scripts/gen-hooks.mjs wrote into hooks.json, with room
|
||||
// to spare: if Grok Build kills us first, the fail-open path below never gets to run.
|
||||
const waitMs = Math.min(
|
||||
approvalHookTimeoutSecs(cfg) * 1000 - 10_000,
|
||||
Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000,
|
||||
);
|
||||
|
||||
try {
|
||||
const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs);
|
||||
const { data } = await postJson(
|
||||
`${baseUrl(cfg)}/hook/approve`,
|
||||
payload,
|
||||
waitMs,
|
||||
hookHeaders(),
|
||||
);
|
||||
if (data && data.decision === "deny") deny(data.reason);
|
||||
allow();
|
||||
} catch {
|
||||
|
||||
+59
-2
@@ -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),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Passive recorder hook: POST one lifecycle event into the daemon and get out of the way.
|
||||
*
|
||||
* Wired to every observed event. This used to be a `type: "http"` hook — no process spawn at
|
||||
* all — until it turned out that Grok Build's http runner validates the URL against its SSRF
|
||||
* rules and rejects any scheme that is not https (crates/codegen/xai-grok-hooks/src/runner/
|
||||
* http.rs, `validate_hook_url`). The daemon speaks plain http on loopback, so an http hook
|
||||
* could never reach it: those hooks were failing validation, silently, on every event.
|
||||
*
|
||||
* A command hook costs a Node start (~40ms) per event, and buys back the ability to send an
|
||||
* authentication header, which the http runner has no config surface for.
|
||||
*
|
||||
* Always exits 0. A dashboard must never be the reason a tool call fails.
|
||||
*/
|
||||
|
||||
import {
|
||||
baseUrl,
|
||||
envEnvelope,
|
||||
hookHeaders,
|
||||
postJson,
|
||||
readConfig,
|
||||
readStdinJson,
|
||||
} from "./glance-lib.mjs";
|
||||
|
||||
try {
|
||||
const payload = envEnvelope(await readStdinJson());
|
||||
const cfg = readConfig();
|
||||
// Short: if the daemon is not listening this must fail fast, not hold up the tool call.
|
||||
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2000, hookHeaders());
|
||||
} catch {
|
||||
// Daemon down, no secret yet, malformed payload — all the same answer: carry on.
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
+4
-1
@@ -14,6 +14,7 @@ import {
|
||||
baseUrl,
|
||||
envEnvelope,
|
||||
glanceHome,
|
||||
hookHeaders,
|
||||
isDaemonUp,
|
||||
postJson,
|
||||
readConfig,
|
||||
@@ -59,7 +60,9 @@ const cfg = readConfig();
|
||||
try {
|
||||
const up = await ensureDaemon(cfg);
|
||||
if (up) {
|
||||
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500);
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user