Drop the hook-template scheme and other dead weight

hooks/hooks.json was generated from a template by scripts/gen-hooks.mjs. Once every
hook became a command hook that reads hook.secret from $GLANCE_HOME itself, the
template had exactly two placeholders left: {{HOOK_TOKEN}}, which nothing had ever
substituted into anything, and {{APPROVAL_TIMEOUT_SECS}}. Generating a whole file to
compute one number is not a good trade, so the number is now fixed at 125s in the
committed hooks.json and the coupling is enforced in code instead: the daemon clamps
approval.timeoutMs to APPROVAL_MAX_WAIT_MS (90s), which keeps the script inside its
own hook timeout no matter what a hand-edited config.json says. Losing that clamp is
what would actually hurt — a killed script never runs its fail-open path.

Also removed:

- `glance sync-hooks`, `npm run build:hooks`, and hookSecret({create}). The daemon is
  the only thing that should ever mint the secret.
- The ?k= query-string carrier for the hook secret. It existed for hooks that cannot
  set headers; there are none, and a secret in a URL lands in logs and shell history.
- Snapshot.now and SessionView.startedAt, which were written on every snapshot and
  every persist and read by nobody.
- An unused crypto import.

Docs and the e2e suite follow. The suite's ~12 sync-hooks assertions become static
checks on the committed file, plus new ones that hooks.json, APPROVAL_HOOK_TIMEOUT_SECS
and APPROVAL_MAX_WAIT_MS still agree, and that ?k= is refused. 220 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iceBear67
2026-08-09 07:04:50 +00:00
co-authored by Claude Opus 5
parent 6d34a17d9d
commit d20eb9255c
15 changed files with 64 additions and 413 deletions
-2
View File
@@ -7,8 +7,6 @@ export const SESSION_COOKIE = "glance_session";
export const CSRF_HEADER = "x-glance-csrf";
/** Shared-secret header presented by the hook scripts on /hook/*. */
export const HOOK_HEADER = "x-glance-hook";
/** Query-string carrier for the same secret, for hooks that cannot set headers. */
export const HOOK_QUERY_PARAM = "k";
export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
+16
View File
@@ -82,6 +82,21 @@ export function ensureHome(): void {
}
}
/**
* The longest the daemon may hold a tool call waiting for a tap.
*
* hooks/hooks.json gives the approval hook a fixed 125s timeout, and glance-approve.mjs keeps
* 10s of that for itself. Waiting longer than this would get the script killed mid-wait, and a
* killed script never runs its fail-open path — so a hand-edited config.json is clamped rather
* than believed.
*/
export const APPROVAL_MAX_WAIT_MS = 90_000;
function clampApprovalWait(ms: number): number {
if (!Number.isFinite(ms) || ms <= 0) return DEFAULTS.approval.timeoutMs;
return Math.min(ms, APPROVAL_MAX_WAIT_MS);
}
export function loadConfig(): Config {
ensureHome();
let stored: Partial<Config> = {};
@@ -95,6 +110,7 @@ export function loadConfig(): Config {
...stored,
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
};
merged.approval.timeoutMs = clampApprovalWait(merged.approval.timeoutMs);
if (process.env.GLANCE_PORT) {
const p = Number(process.env.GLANCE_PORT);
if (Number.isFinite(p)) merged.port = p;
+6 -7
View File
@@ -32,7 +32,6 @@ import {
CSRF_HEADER,
EnrollmentCodes,
HOOK_HEADER,
HOOK_QUERY_PARAM,
RateLimiter,
SESSION_COOKIE,
buildSessionCookie,
@@ -119,16 +118,16 @@ function isAdmin(req: http.IncomingMessage): boolean {
* loopback, so without a check anyone on the tailnet could POST forged events into the
* timeline and answer /hook/approve on your behalf. Two independent barriers:
*
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), presented as a header or,
* for hook types that cannot set one, as `?k=`. Compared in constant time.
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), sent as a header and
* compared in constant time. Header only: a secret in a query string ends up in logs
* and shell history, and every caller here is a local process that can set one.
* 2. The request must not have been proxied. Tailscale stamps `x-forwarded-*` on anything
* it tunnels, so their presence means the caller is not a local process — which no
* real hook ever is. This keeps a leaked secret from being usable off-box.
*/
function isHookCaller(req: http.IncomingMessage, url: URL): boolean {
function isHookCaller(req: http.IncomingMessage): boolean {
if (header(req, "x-forwarded-for") || header(req, "x-forwarded-proto")) return false;
const provided = header(req, HOOK_HEADER) ?? url.searchParams.get(HOOK_QUERY_PARAM);
return sameSecret(provided, hookToken);
return sameSecret(header(req, HOOK_HEADER), hookToken);
}
/**
@@ -185,7 +184,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
return;
}
// Before reading a body: an unauthenticated caller gets to spend nothing here.
if (!isHookCaller(req, url)) {
if (!isHookCaller(req)) {
out.json(403, { error: "hook token required" });
return;
}
-2
View File
@@ -59,7 +59,6 @@ export interface SessionView {
badge: number;
cwd: string;
state: SessionState;
startedAt: number;
lastActivity: number;
lastPrompt?: string;
/** Tool calls in flight, oldest first — an agent can run several at once. */
@@ -93,7 +92,6 @@ export interface ApprovalSettings {
}
export interface Snapshot {
now: number;
version: string;
sessions: SessionView[];
events: GlanceEvent[];
-3
View File
@@ -126,7 +126,6 @@ export class GlanceState {
badge: this.nextBadge++,
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
state: "idle",
startedAt: Date.now(),
lastActivity: Date.now(),
running: [],
counts: { tools: 0, failures: 0, denials: 0 },
@@ -397,7 +396,6 @@ export class GlanceState {
.sort((a, b) => ATTENTION_RANK[a.state] - ATTENTION_RANK[b.state] || a.badge - b.badge);
return {
now,
version: VERSION,
sessions,
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
@@ -458,7 +456,6 @@ function restoreSession(raw: unknown): SessionView | null {
badge: Math.max(1, Math.floor(s.badge)),
cwd: typeof s.cwd === "string" ? s.cwd : "",
state: s.state && s.state in ATTENTION_RANK ? s.state : "idle",
startedAt: typeof s.startedAt === "number" ? s.startedAt : Date.now(),
lastActivity: typeof s.lastActivity === "number" ? s.lastActivity : 0,
lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined,
// Nothing survives the restart: whatever reports the end of a tool call was talking to