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:
co-authored by
Claude Opus 5
parent
6d34a17d9d
commit
d20eb9255c
@@ -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> = {};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user