diff --git a/README.md b/README.md index 61d92d7..81801bb 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,10 @@ npm install && npm run build ``` The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required; -the daemon serves the dashboard itself. It also generates `hooks/hooks.json` from -`hooks/hooks.template.json`, and creates `~/.grok/glance/hook.secret` (mode 0600) if it does not -exist yet — the shared secret the hook scripts authenticate with. Neither the secret nor anything -derived from it ends up in `hooks.json`. +the daemon serves the dashboard itself. `hooks/hooks.json` is checked in as-is — nothing about it +is generated or machine-specific. The shared secret the hook scripts authenticate with lives in +`~/.grok/glance/hook.secret` (mode 0600) and is created by the daemon on first start; it never +appears in `hooks.json`. Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so for a local checkout the shortest path is a one-entry catalog. Create @@ -184,7 +184,7 @@ Defaults worth knowing: |---|---|---|---| | Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. | | On timeout | allow | yes | Flip to *deny* if you would rather fail closed. | -| Timeout | 90s | no — edit `config.json` | The hook's own timeout is derived from this (`+35s` of slack) when `hooks.json` is generated, so re-run `sync-hooks` after changing it. | +| Timeout | 90s | no — edit `config.json` | Also the ceiling: the approval hook gets 125s in `hooks.json`, and the daemon clamps a larger `timeoutMs` down to 90s so the script always outlives its own wait. | | Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. | **This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon @@ -208,7 +208,6 @@ permission settings. | `devices` | List enrolled devices | | `revoke ` | Revoke a device | | `approval ` | Set the approval policy | -| `sync-hooks` | Regenerate `hooks/hooks.json` from the template (after changing `config.json`) | ## Files and configuration @@ -235,10 +234,8 @@ instance without touching your real one: | `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) | | `GLANCE_ORIGIN` | Public origin, as if set with `set-origin` — but not persisted | -The port is not baked into `hooks/hooks.json` — the hook scripts read `config.json` themselves — so -changing it needs nothing but a daemon restart. Changing `approval.timeoutMs` does affect the -generated file: run `node bin/glance sync-hooks` afterwards so the approval hook's own timeout still -outlasts the wait. +Nothing in `hooks/hooks.json` is machine-specific: the hook scripts read `config.json` themselves, +so changing the port or `approval.timeoutMs` needs nothing but a daemon restart. ## Security notes @@ -308,9 +305,8 @@ stop working and must be enrolled again. ## Hook wiring -`hooks/hooks.json` subscribes to all 14 lifecycle events, and **is generated** — from -`hooks/hooks.template.json` by `scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on -`node bin/glance sync-hooks`. Edit the template, not the output. +`hooks/hooks.json` subscribes to all 14 lifecycle events and is a plain checked-in file — edit it +directly. Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner rejects every scheme but `https`, then **resolves the host** @@ -319,9 +315,8 @@ and refuses the resolved address if it is private, link-local or CGNAT fails the scheme check; the tailnet fails the address check, because `*.ts.net` resolves into `100.64/10` (and `fd7a::/48`, inside the blocked `fc00::/7`). On top of that the runner sends no request header but `Content-Type`, with no configuration surface for one, so such a hook could not -authenticate itself even if it could connect. The generator refuses to emit an `http` handler whose -URL is not `https://`, because the alternative is what this plugin shipped for a while: 13 passive -hooks that failed validation silently on every event. +authenticate itself even if it could connect. This plugin shipped `http` hooks for a while, and the +result was 13 passive hooks failing validation silently on every event. A command hook has none of those problems. It is a local process, so no URL is validated, nothing traverses the tunnel, and it can present the shared secret — hook traffic goes straight to @@ -333,8 +328,8 @@ traverses the tunnel, and it can present the shared secret — hook traffic goes only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs `bin/glance-approve.mjs`. PreToolUse is the only blocking event, and a command hook is the only documented way to return a deny decision; keeping the match narrow means the gate's cost is paid only for calls that could - actually need a tap. Its `timeout` is derived from `approval.timeoutMs` at generation time rather - than hand-copied, which is the other thing `sync-hooks` refreshes. + actually need a tap. Its `timeout` is a fixed 125s, and the daemon clamps its own wait to 90s + against it (`APPROVAL_MAX_WAIT_MS`) so the script is never killed before it can fail open. The hook scripts use nothing but the Node standard library and always exit 0 unless they are deliberately denying — including when the daemon rejects their token. diff --git a/bin/glance b/bin/glance index b13e7cb..8a5471e 100755 --- a/bin/glance +++ b/bin/glance @@ -10,7 +10,7 @@ import fs from "node:fs"; import path from "node:path"; -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { PLUGIN_ROOT, baseUrl, @@ -221,23 +221,6 @@ switch (cmd) { break; } - case "sync-hooks": { - // 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"); - } - break; - } - case "logs": { const file = path.join(glanceHome(), "daemon.log"); if (!fs.existsSync(file)) { @@ -260,7 +243,6 @@ switch (cmd) { glance devices list enrolled devices glance revoke revoke a device glance approval remote approval policy - glance sync-hooks regenerate hooks/hooks.json from the template glance logs tail the daemon log `); } diff --git a/bin/glance-approve.mjs b/bin/glance-approve.mjs index d4841f8..9d1e73e 100755 --- a/bin/glance-approve.mjs +++ b/bin/glance-approve.mjs @@ -11,7 +11,7 @@ */ import { - approvalHookTimeoutSecs, + APPROVAL_HOOK_TIMEOUT_SECS, baseUrl, envEnvelope, hookHeaders, @@ -36,10 +36,10 @@ function deny(reason) { const payload = envEnvelope(await readStdinJson()); const cfg = readConfig(); -// 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. +// Finish inside the hook's own timeout from 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, + APPROVAL_HOOK_TIMEOUT_SECS * 1000 - 10_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000, ); diff --git a/bin/glance-lib.mjs b/bin/glance-lib.mjs index 5f4f2bd..0c4e762 100644 --- a/bin/glance-lib.mjs +++ b/bin/glance-lib.mjs @@ -8,7 +8,6 @@ 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), "../.."); @@ -49,34 +48,15 @@ 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. + * regenerated secret is picked up without restarting anything. * - * `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". + * Only the daemon ever creates it. A hook must never be the thing that creates state, and a + * missing secret has to degrade to "no telemetry", not "no tool call" — so this returns null + * and the callers carry on. */ -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 */ - } - } +export function hookSecret() { try { - return fs.readFileSync(file, "utf8").trim() || null; + return fs.readFileSync(path.join(glanceHome(), "hook.secret"), "utf8").trim() || null; } catch { return null; } @@ -88,17 +68,15 @@ export function hookHeaders() { } /** - * How long the PreToolUse approval hook is allowed to run, in seconds. + * How long the PreToolUse approval hook is allowed to run, in seconds — the `timeout` written + * next to glance-approve.mjs in hooks/hooks.json. Change one, change the other. * - * 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. + * It bounds everything downstream: if the script outlives its hook timeout, Grok Build kills + * it and the fail-open path never runs. So the daemon caps its own wait well inside it (see + * APPROVAL_MAX_WAIT_MS in server/src/config.ts), and the script leaves itself 10s on top of + * that to answer. */ -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; -} +export const APPROVAL_HOOK_TIMEOUT_SECS = 125; /** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */ export async function readStdinJson() { diff --git a/hooks/hooks.json b/hooks/hooks.json index cadab45..3c3153a 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,9 +1,6 @@ { "_comment": [ - "TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by", - "`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.", - "", - "Everything is a `command` hook, including the 13 passive recorders. That is not a style", + "Every entry is a `command` hook, including the 13 passive recorders. That is not a style", "choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner", "(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,", "then resolves the host and refuses the resolved address if it is private, link-local or", @@ -14,26 +11,19 @@ "could connect.", "", "A command hook has none of those problems: it is a local process, so there is no URL to", - "validate, no proxy in the path, and it can present the shared secret. It costs one Node", - "start (~40ms) per event.", + "validate, no proxy in the path, and it reads the shared secret out of $GLANCE_HOME itself.", + "It costs one Node start (~40ms) per event. Nothing here is secret, and nothing here is", + "derived from config.json - the scripts read that themselves - so this file is plain,", + "committed, and edited by hand.", "", "SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records", "every call for the timeline, and a second, narrowly matched entry runs the approval gate,", "because PreToolUse is the only blocking event and only a command hook can return a deny.", "", - "Placeholders, written in the template as a name wrapped in double braces:", - " APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.", - " HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that", - " cannot set a header: the daemon accepts it as a ?k= query", - " parameter too. Nothing below uses it and no http hook can (see", - " above); it stays because the daemon's ?k= path is real. Note the", - " daemon also refuses any /hook/* request carrying x-forwarded-*,", - " so a reverse-proxied transport is out as well. Using this", - " placeholder makes the generated hooks.json secret-bearing, so", - " gen-hooks writes it 0600 and it must not be committed.", - "", - "Do not spell those names with their braces anywhere in this comment block: the comment is", - "copied verbatim into hooks.json, and substitution would happily expand it there too." + "The gate's 125s timeout is the ceiling for the whole approval round trip. The daemon caps", + "approval.timeoutMs at 90s against it, so the script always outlives its own wait and gets", + "to fail open. Changing the number here means changing APPROVAL_HOOK_TIMEOUT_SECS in", + "bin/glance-lib.mjs and APPROVAL_MAX_WAIT_MS in server/src/config.ts to match." ], "hooks": { "SessionStart": [ diff --git a/hooks/hooks.template.json b/hooks/hooks.template.json deleted file mode 100644 index 9e78f5d..0000000 --- a/hooks/hooks.template.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "_comment": [ - "TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by", - "`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.", - "", - "Everything is a `command` hook, including the 13 passive recorders. That is not a style", - "choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner", - "(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,", - "then resolves the host and refuses the resolved address if it is private, link-local or", - "CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves", - "into 100.64/10 (and fd7a::/48, inside the blocked fc00::/7). Pointing a hook at the public", - "https origin therefore fails upstream, before a request is ever sent. The runner also sends", - "no request header but Content-Type, so such a hook could not authenticate itself even if it", - "could connect.", - "", - "A command hook has none of those problems: it is a local process, so there is no URL to", - "validate, no proxy in the path, and it can present the shared secret. It costs one Node", - "start (~40ms) per event.", - "", - "SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records", - "every call for the timeline, and a second, narrowly matched entry runs the approval gate,", - "because PreToolUse is the only blocking event and only a command hook can return a deny.", - "", - "Placeholders, written in the template as a name wrapped in double braces:", - " APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.", - " HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that", - " cannot set a header: the daemon accepts it as a ?k= query", - " parameter too. Nothing below uses it and no http hook can (see", - " above); it stays because the daemon's ?k= path is real. Note the", - " daemon also refuses any /hook/* request carrying x-forwarded-*,", - " so a reverse-proxied transport is out as well. Using this", - " placeholder makes the generated hooks.json secret-bearing, so", - " gen-hooks writes it 0600 and it must not be committed.", - "", - "Do not spell those names with their braces anywhere in this comment block: the comment is", - "copied verbatim into hooks.json, and substitution would happily expand it there too." - ], - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"", - "timeout": 20 - } - ] - } - ], - "PreToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", - "timeout": 5 - } - ] - }, - { - "matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$", - "hooks": [ - { - "type": "command", - "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"", - "timeout": "{{APPROVAL_TIMEOUT_SECS}}" - } - ] - } - ], - "PostToolUse": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "PostToolUseFailure": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "PermissionDenied": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "Notification": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "Stop": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "StopFailure": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "SubagentStart": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "SubagentStop": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "PreCompact": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "PostCompact": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } - ] - } - ] - } -} diff --git a/package.json b/package.json index f748f3d..7112813 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,9 @@ "node": ">=20" }, "scripts": { - "build": "npm run build:server && npm run build:web && npm run build:hooks", + "build": "npm run build:server && npm run build:web", "build:server": "tsc -p tsconfig.server.json", "build:web": "tsc -p tsconfig.web.json && vite build", - "build:hooks": "node scripts/gen-hooks.mjs", "dev": "vite", "start": "node dist/server/index.js", "glance": "node bin/glance" diff --git a/scripts/gen-hooks.mjs b/scripts/gen-hooks.mjs deleted file mode 100644 index a7b340b..0000000 --- a/scripts/gen-hooks.mjs +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env node -/** - * Generate hooks/hooks.json from hooks/hooks.template.json. - * - * Runs as part of `npm run build`, and again on `glance sync-hooks`. Two jobs: - * - * 1. Make sure the /hook/* shared secret exists (mode 0600, in $GLANCE_HOME). The daemon - * requires it; without it the hook scripts are just anonymous POSTs, which is what this - * whole mechanism exists to stop. - * 2. Substitute the placeholders the template declares, so values that are really derived - * from config.json - the approval hook's timeout above all - stop being hand-copied - * constants that drift. - * - * It also refuses to emit a hook that cannot work. An `http` handler pointed at a non-https - * URL is the specific mistake that made every passive hook in this plugin a no-op for a - * while: Grok Build's http runner puts every URL through SSRF validation and rejects any - * other scheme outright (xai-grok-hooks/src/runner/http.rs, `validate_hook_url`). - */ - -import fs from "node:fs"; -import path from "node:path"; -import { PLUGIN_ROOT, approvalHookTimeoutSecs, hookSecret, readConfig } from "../bin/glance-lib.mjs"; - -const TEMPLATE = path.join(PLUGIN_ROOT, "hooks", "hooks.template.json"); -const OUTPUT = path.join(PLUGIN_ROOT, "hooks", "hooks.json"); - -const cfg = readConfig(); -const token = hookSecret({ create: true }); -if (!token) { - console.error(`gen-hooks: could not create the hook secret in ${process.env.GLANCE_HOME ?? "~/.grok/glance"}`); - process.exit(1); -} - -const timeoutSecs = approvalHookTimeoutSecs(cfg); - -let text = fs.readFileSync(TEMPLATE, "utf8"); - -// The quoted form first, so a JSON-valid template can carry a value that must end up numeric. -text = text - .split(`"{{APPROVAL_TIMEOUT_SECS}}"`) - .join(String(timeoutSecs)) - .split("{{APPROVAL_TIMEOUT_SECS}}") - .join(String(timeoutSecs)); - -const embedsToken = text.includes("{{HOOK_TOKEN}}"); -text = text.split("{{HOOK_TOKEN}}").join(token); - -// Trust the emitted bytes, not the placeholder: a template that mentions the placeholder in a -// comment would otherwise ship the real secret in a world-readable file. (It did once.) -const tokenIsInOutput = text.includes(token); -if (tokenIsInOutput && !embedsToken) { - console.error("gen-hooks: the hook secret leaked into hooks.json from somewhere unexpected"); - process.exit(1); -} - -const leftover = text.match(/\{\{[A-Z_]+\}\}/); -if (leftover) { - console.error(`gen-hooks: unknown placeholder ${leftover[0]} in hooks.template.json`); - process.exit(1); -} - -/* ------------------------------------------------------------------ validate */ - -let doc; -try { - doc = JSON.parse(text); -} catch (err) { - console.error(`gen-hooks: template did not produce valid JSON: ${err.message}`); - process.exit(1); -} - -const problems = []; -let handlers = 0; - -for (const [event, groups] of Object.entries(doc.hooks ?? {})) { - if (!Array.isArray(groups)) { - problems.push(`${event}: expected an array of matcher groups`); - continue; - } - for (const group of groups) { - for (const h of group.hooks ?? []) { - handlers++; - const where = `${event} -> ${h.command ?? h.url ?? "(no target)"}`; - if (h.type !== "command" && h.type !== "http") { - problems.push(`${where}: type must be "command" or "http", got ${JSON.stringify(h.type)}`); - continue; - } - if (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0) { - problems.push(`${where}: timeout must be a positive number of seconds`); - } - if (h.type === "http") { - // The runner rejects every scheme but https, and treats RFC1918 / CGNAT / link-local - // targets as SSRF. That rules out both loopback-over-http and Tailscale's 100.64/10. - if (!/^https:\/\//.test(h.url ?? "")) { - problems.push( - `${where}: http handlers must use an https:// URL - Grok Build's SSRF check ` + - `rejects anything else, so this hook would never fire`, - ); - } - } else { - const script = /bin\/([A-Za-z0-9._-]+)/.exec(h.command ?? ""); - if (!script) { - problems.push(`${where}: could not tell which script this command runs`); - } else if (!fs.existsSync(path.join(PLUGIN_ROOT, "bin", script[1]))) { - problems.push(`${where}: bin/${script[1]} does not exist`); - } - } - } - } -} - -if (problems.length) { - console.error("gen-hooks: refusing to write hooks.json\n"); - for (const p of problems) console.error(` - ${p}`); - process.exit(1); -} - -/* --------------------------------------------------------------------- write */ - -const mode = tokenIsInOutput ? 0o600 : 0o644; -const previous = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, "utf8") : null; - -if (previous === text) { - // Leave the mtime alone: a no-op build should not look like a change. - fs.chmodSync(OUTPUT, mode); - console.log(`hooks.json already current (${handlers} handlers, approval timeout ${timeoutSecs}s)`); -} else { - fs.writeFileSync(OUTPUT, text, { mode }); - fs.chmodSync(OUTPUT, mode); - console.log( - `wrote hooks/hooks.json - ${handlers} handlers, approval timeout ${timeoutSecs}s` + - (previous === null ? " (new file)" : ""), - ); -} - -if (tokenIsInOutput) { - console.warn( - "gen-hooks: hooks.json now contains the hook secret (via the HOOK_TOKEN placeholder); it is " + - "mode 0600 and must not be committed.", - ); -} diff --git a/server/src/auth.ts b/server/src/auth.ts index c0f99f4..3776caa 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -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 { const out: Record = {}; diff --git a/server/src/config.ts b/server/src/config.ts index b63196f..b85840e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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 = {}; @@ -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; diff --git a/server/src/index.ts b/server/src/index.ts index e514617..be2aa94 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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; } diff --git a/server/src/protocol.ts b/server/src/protocol.ts index 4495e72..1ca10cc 100644 --- a/server/src/protocol.ts +++ b/server/src/protocol.ts @@ -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[]; diff --git a/server/src/state.ts b/server/src/state.ts index 0a3191b..3d054ef 100644 --- a/server/src/state.ts +++ b/server/src/state.ts @@ -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 diff --git a/skills/glance/SKILL.md b/skills/glance/SKILL.md index 93675ae..a5965a2 100644 --- a/skills/glance/SKILL.md +++ b/skills/glance/SKILL.md @@ -37,7 +37,6 @@ glance set-origin # set the public origin and WebAuthn RP ID glance devices # list enrolled devices glance revoke # revoke one glance approval # remote approve/deny policy -glance sync-hooks # regenerate hooks/hooks.json from hooks/hooks.template.json ``` ## Watching several agents @@ -99,8 +98,7 @@ and wait for a tap on the phone. Defaults that matter: - **Dashboard loads but shows nothing** → hooks are not firing. `glance status` warns if the hook secret in `$GLANCE_HOME/hook.secret` no longer matches the one the daemon loaded (events are being dropped with a 403); `glance stop && glance up` fixes that. Otherwise check that - `hooks/hooks.json` exists and is registered — it is generated, so `glance sync-hooks` rebuilds it - from the template. + `hooks/hooks.json` exists and that the plugin is registered with Grok Build. - **Page says "run npm install && npm run build"** → the web bundle is missing; build it. - **Only one agent shows up** → the others were started before the plugin was installed, or in an environment where the hooks are not registered. A session appears on its next hook event; nothing diff --git a/web/src/protocol.ts b/web/src/protocol.ts index 4495e72..1ca10cc 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -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[];