/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>
165 lines
5.2 KiB
JavaScript
165 lines
5.2 KiB
JavaScript
/**
|
|
* Shared helpers for the grok-glance hook scripts and CLI.
|
|
*
|
|
* Deliberately dependency-free and stdlib-only: these run on the critical path of
|
|
* every Grok Build tool call, so they must start fast and never wedge a session.
|
|
*/
|
|
|
|
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), "../..");
|
|
|
|
export const DEFAULT_PORT = 8791;
|
|
|
|
/**
|
|
* State lives in one fixed place so that hooks (which get GROK_PLUGIN_DATA) and the
|
|
* CLI (which does not) always agree on where config, credentials and events are.
|
|
*/
|
|
export function glanceHome() {
|
|
if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME);
|
|
return path.join(os.homedir(), ".grok", "glance");
|
|
}
|
|
|
|
export function readConfig() {
|
|
const file = path.join(glanceHome(), "config.json");
|
|
let raw = {};
|
|
try {
|
|
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
// No config yet, or unreadable: defaults are always usable.
|
|
}
|
|
const port = Number(process.env.GLANCE_PORT ?? raw.port ?? DEFAULT_PORT);
|
|
return {
|
|
...raw,
|
|
port: Number.isFinite(port) ? port : DEFAULT_PORT,
|
|
host: raw.host ?? "127.0.0.1",
|
|
};
|
|
}
|
|
|
|
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 {};
|
|
const chunks = [];
|
|
try {
|
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
} catch {
|
|
return {};
|
|
}
|
|
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
if (!text) return {};
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Grok Build also passes the event in the environment. We merge it in so a payload that
|
|
* is missing fields (or absent entirely) still produces a usable event.
|
|
*/
|
|
export function envEnvelope(payload) {
|
|
return {
|
|
hookEventName: payload.hookEventName ?? process.env.GROK_HOOK_EVENT ?? "Unknown",
|
|
sessionId: payload.sessionId ?? process.env.GROK_SESSION_ID ?? "unknown",
|
|
workspaceRoot:
|
|
payload.workspaceRoot ?? process.env.GROK_WORKSPACE_ROOT ?? payload.cwd ?? process.cwd(),
|
|
cwd: payload.cwd ?? process.cwd(),
|
|
hookName: process.env.GROK_HOOK_NAME ?? undefined,
|
|
...payload,
|
|
};
|
|
}
|
|
|
|
export async function postJson(url, body, timeoutMs, extraHeaders = {}) {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", ...extraHeaders },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const text = await res.text();
|
|
if (!text) return { status: res.status, data: null };
|
|
try {
|
|
return { status: res.status, data: JSON.parse(text) };
|
|
} catch {
|
|
return { status: res.status, data: null };
|
|
}
|
|
}
|
|
|
|
export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) {
|
|
try {
|
|
const res = await fetch(`${baseUrl(cfg)}/healthz`, {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|