diff --git a/README.md b/README.md index 2f643ec..e70a72d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,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. +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`. 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 @@ -144,12 +147,13 @@ 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 125s; raising this past that would just make the hook give up first. | +| 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. | | 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 -down, hook timeout, malformed response, port mismatch — the tool call proceeds. If you need calls -actually blocked, use Grok Build's own permission settings. +down, hook timeout, malformed response, port mismatch, a hook secret the daemon no longer +recognises — the tool call proceeds. If you need calls actually blocked, use Grok Build's own +permission settings. ## CLI @@ -167,7 +171,7 @@ actually blocked, use Grok Build's own permission settings. | `devices` | List enrolled devices | | `revoke ` | Revoke a device | | `approval ` | Set the approval policy | -| `sync-hooks` | Rewrite hook URLs after changing the port | +| `sync-hooks` | Regenerate `hooks/hooks.json` from the template (after changing `config.json`) | ## Files and configuration @@ -180,6 +184,7 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i | `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens | | `secret.key` | 32-byte HMAC key used to sign session cookies | | `admin.token` | Rotated every daemon start; authenticates the CLI | +| `hook.secret` | Shared secret the hook scripts present on `/hook/*`. Created once, mode 0600, never rotated — a rotation mid-session would 403 whatever was already in flight. Delete it and the daemon mints a new one on its next start; hooks then need that restart to agree again, which `glance status` will tell you about. | | `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB | | `daemon.log` | Daemon stdout/stderr | @@ -192,8 +197,10 @@ 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 | -To change the port, edit `config.json`, then run `node bin/glance sync-hooks` so the hook URLs in -`hooks/hooks.json` match. Restart the daemon afterwards. +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. ## Security notes @@ -202,13 +209,20 @@ its own. Three classes of caller: | Path | Caller | Authentication | |---|---|---| -| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | none — loopback only | +| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | shared secret from `hook.secret`, plus a refusal of any proxied request | | `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header | | `/local/*` | the `glance` CLI | rotating admin token from `admin.token` | -`/local/*` is token-gated rather than "is it from localhost", because `tailscale serve` proxies -remote traffic to `127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by -address alone. +None of the three trusts the source address, because `tailscale serve` proxies remote traffic to +`127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by address alone. Without +the hook secret, anyone who could reach the tunnel could forge timeline events and answer approval +prompts on your behalf; `/hook/*` compares the secret in constant time before it reads a body, and +additionally refuses any request carrying `x-forwarded-for` or `x-forwarded-proto`, which a local +hook process never sends and a tunnelled caller always does. + +That refusal costs nothing, because no legitimate hook traffic comes through the tunnel: hooks are +local processes talking to loopback. It applies **only** to `/hook/*` — the dashboard arrives +through `tailscale serve` with those headers on every request and is unaffected. **Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions @@ -256,21 +270,36 @@ stop working and must be enrolled again. ## Hook wiring -`hooks/hooks.json` subscribes to all 14 lifecycle events. Passive events use `type: "http"`: they -POST straight into the daemon with no process spawn, so they cost close to nothing per tool call -and quietly do nothing when the daemon is down. +`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. -Two exceptions: +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** +and refuses the resolved address if it is private, link-local or CGNAT +(`xai-grok-hooks/src/runner/http.rs`, `validate_hook_url` + `is_blocked_ip`). Plain http on loopback +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. + +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 +`http://127.0.0.1:8791` and never leaves the machine. So each observed event runs +`bin/glance-record.mjs`, which costs a Node start (~40 ms) and POSTs one event. Two entries differ: - `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon. -- `PreToolUse` is wired **twice** — an `http` entry that records every call for the timeline, and a - `command` entry matching 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, so the gate has to be a spawned process; keeping the - match narrow means the cost is paid only for calls that could actually need a tap. +- `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second entry matching + 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. The hook scripts use nothing but the Node standard library and always exit 0 unless they are -deliberately denying. +deliberately denying — including when the daemon rejects their token. ## Deliberately omitted diff --git a/bin/glance b/bin/glance index fa10fda..f205b75 100755 --- a/bin/glance +++ b/bin/glance @@ -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 revoke a device glance approval 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 `); } diff --git a/bin/glance-approve.mjs b/bin/glance-approve.mjs index 7437fec..d4841f8 100755 --- a/bin/glance-approve.mjs +++ b/bin/glance-approve.mjs @@ -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 { diff --git a/bin/glance-lib.mjs b/bin/glance-lib.mjs index 5d5a815..5f4f2bd 100644 --- a/bin/glance-lib.mjs +++ b/bin/glance-lib.mjs @@ -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), }); diff --git a/bin/glance-record.mjs b/bin/glance-record.mjs new file mode 100644 index 0000000..e97363d --- /dev/null +++ b/bin/glance-record.mjs @@ -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); diff --git a/bin/glance-up.mjs b/bin/glance-up.mjs index 5e42ee5..357f287 100755 --- a/bin/glance-up.mjs +++ b/bin/glance-up.mjs @@ -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. diff --git a/hooks/hooks.json b/hooks/hooks.json index 74c6187..cadab45 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,14 +1,39 @@ { "_comment": [ - "grok-glance hook wiring.", - "Passive events use type=http: they POST straight into the daemon with no process spawn,", - "so they cost ~nothing per tool call and fail open if the daemon is not running.", - "SessionStart uses type=command because it is what boots the daemon.", - "PreToolUse is wired twice on purpose: an http entry records every tool call for the", - "timeline, and a command entry gates only risky tools, because only a command hook has a", - "documented way to return a deny decision.", - "If you change the port in ~/.grok/glance/config.json, run `glance sync-hooks` to rewrite", - "the URLs below, or edit them by hand." + "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": [ @@ -26,9 +51,9 @@ { "hooks": [ { - "type": "http", - "url": "http://127.0.0.1:8791/hook/record", - "timeout": 3 + "type": "command", + "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", + "timeout": 5 } ] }, @@ -45,62 +70,86 @@ ], "PostToolUse": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "PostToolUseFailure": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "UserPromptSubmit": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "PermissionDenied": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "Notification": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "Stop": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "StopFailure": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "SubagentStart": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "SubagentStop": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "PreCompact": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "PostCompact": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ], "SessionEnd": [ { - "hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }] + "hooks": [ + { "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 } + ] } ] } diff --git a/hooks/hooks.template.json b/hooks/hooks.template.json new file mode 100644 index 0000000..9e78f5d --- /dev/null +++ b/hooks/hooks.template.json @@ -0,0 +1,156 @@ +{ + "_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 7112813..f748f3d 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ "node": ">=20" }, "scripts": { - "build": "npm run build:server && npm run build:web", + "build": "npm run build:server && npm run build:web && npm run build:hooks", "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 new file mode 100644 index 0000000..a7b340b --- /dev/null +++ b/scripts/gen-hooks.mjs @@ -0,0 +1,141 @@ +#!/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 4d2331c..c0f99f4 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -5,6 +5,10 @@ import type { IncomingMessage } from "node:http"; 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 8bf4193..e437665 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -43,6 +43,9 @@ export const paths = { get adminToken() { return path.join(glanceHome(), "admin.token"); }, + get hookSecret() { + return path.join(glanceHome(), "hook.secret"); + }, get events() { return path.join(glanceHome(), "events.jsonl"); }, diff --git a/server/src/index.ts b/server/src/index.ts index 8b01fcb..bfbe533 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,10 +3,13 @@ * * One small http server with three kinds of caller: * - * /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one. + * /hook/* the plugin's hook scripts, gated by the shared secret in hook.secret. * /api/* the web app, gated by a passkey-backed cookie session. * /local/* the `glance` CLI, gated by a rotating admin token on disk. * + * None of the three trusts the source address: `tailscale serve` proxies tailnet traffic to + * 127.0.0.1, so every caller looks local. + * * Everything the hooks touch is written to fail open: if this process is confused, wedged, or * gone, Grok Build keeps working. */ @@ -28,6 +31,8 @@ import { import { CSRF_HEADER, EnrollmentCodes, + HOOK_HEADER, + HOOK_QUERY_PARAM, RateLimiter, SESSION_COOKIE, buildSessionCookie, @@ -47,7 +52,9 @@ import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js"; import { destroyAuthSession, deviceList, + hookSecret, lookupAuthSession, + readHookSecretFromDisk, revokeCredentials, rotateAdminToken, sessionSecret, @@ -60,6 +67,7 @@ ensureHome(); const cfg = loadConfig(); const secret = sessionSecret(); const adminToken = rotateAdminToken(); +const hookToken = hookSecret(); const webauthn = new WebAuthnService(cfg); const codes = new EnrollmentCodes(); @@ -93,14 +101,36 @@ function currentSession(req: http.IncomingMessage): Session | null { return { token, credentialId: record.credentialId, label: record.label }; } -function isAdmin(req: http.IncomingMessage): boolean { - const provided = header(req, "x-glance-admin"); +function sameSecret(provided: string | undefined | null, expected: string): boolean { if (!provided) return false; const a = Buffer.from(provided); - const b = Buffer.from(adminToken); + const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } +function isAdmin(req: http.IncomingMessage): boolean { + return sameSecret(header(req, "x-glance-admin"), adminToken); +} + +/** + * Is this really one of our hook scripts? + * + * "It came from 127.0.0.1" proves nothing: `tailscale serve` proxies tailnet traffic to + * 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. + * 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 { + 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); +} + /** * `application/json` is not a CORS-safelisted content type, so requiring it exactly means a * hostile page cannot post here without a preflight we never answer. The custom header on @@ -154,6 +184,11 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom out.json(405, { error: "post json" }); return; } + // Before reading a body: an unauthenticated caller gets to spend nothing here. + if (!isHookCaller(req, url)) { + out.json(403, { error: "hook token required" }); + return; + } const payload = ((await readJson(req)) ?? {}) as HookPayload; if (p === "/hook/record") { @@ -163,8 +198,8 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom } if (p === "/hook/approve") { - // Note: this deliberately does not ingest an event. The PreToolUse http hook already - // recorded the tool call; recording it here too would double every entry. + // Note: this deliberately does not ingest an event. The PreToolUse recording hook + // already logged the tool call; recording it here too would double every entry. const decision = await broker.request(payload); out.json(200, decision); return; @@ -194,6 +229,9 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom events: state.eventCount, webBuilt: webBuildExists(), home: paths.home, + // Would a hook script authenticate right now? The daemon holds the token it read at + // startup; if the file has since changed or gone, recording is silently dropping. + hookAuthOk: sameSecret(readHookSecretFromDisk(), hookToken), }); return; } diff --git a/server/src/store.ts b/server/src/store.ts index 995e495..8777c85 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -69,6 +69,47 @@ export function rotateAdminToken(): string { return token; } +/** + * Token the hook scripts present on /hook/*. Unlike `admin.token` this is *not* rotated on + * every start: hook scripts are separate short-lived processes that read the file per + * invocation, and a rotation mid-session would 403 whatever was already in flight. + * + * It exists because `tailscale serve` proxies tailnet traffic to 127.0.0.1, so "the request + * came from loopback" says nothing about who sent it. Without this, anyone on the tailnet + * could forge timeline events and answer approval prompts. + * + * Created exclusively (`wx`) so two hooks racing on a fresh home cannot end up with + * different values — the loser re-reads the winner's file. + */ +export function hookSecret(): string { + ensureHome(); + for (let attempt = 0; attempt < 2; attempt++) { + try { + const existing = fs.readFileSync(paths.hookSecret, "utf8").trim(); + if (existing) return existing; + } catch { + /* create below */ + } + const token = crypto.randomBytes(32).toString("base64url"); + try { + fs.writeFileSync(paths.hookSecret, token + "\n", { mode: 0o600, flag: "wx" }); + return token; + } catch { + // Lost the race (or the file appeared between the read and the write): read it back. + } + } + return fs.readFileSync(paths.hookSecret, "utf8").trim(); +} + +/** Whatever is on disk right now, for diagnostics. Never creates the file. */ +export function readHookSecretFromDisk(): string | null { + try { + return fs.readFileSync(paths.hookSecret, "utf8").trim() || null; + } catch { + return null; + } +} + /* -------------------------------------------------------------- credentials */ export function listCredentials(): StoredCredential[] { diff --git a/skills/glance/SKILL.md b/skills/glance/SKILL.md index 52c7132..58ed0ab 100644 --- a/skills/glance/SKILL.md +++ b/skills/glance/SKILL.md @@ -34,7 +34,7 @@ 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 # rewrite hook URLs after changing the port +glance sync-hooks # regenerate hooks/hooks.json from hooks/hooks.template.json ``` ## Getting it onto a phone @@ -63,8 +63,8 @@ and wait for a tap on the phone. Defaults that matter: - Nothing waits unless a phone is actually watching the dashboard (`requireWatcher`). - If nobody answers within 90s the call is **allowed**, not denied. Flip that on the phone's settings panel if you want the opposite. -- Every failure path is fail-open: daemon down, timeout, bad JSON — the tool call proceeds. This - is a convenience gate, not a security boundary. +- Every failure path is fail-open: daemon down, timeout, bad JSON, a rejected hook secret — the tool + call proceeds. This is a convenience gate, not a security boundary. `glance approval off` (the default) means Grok Build never blocks on the phone. @@ -73,8 +73,11 @@ and wait for a tap on the phone. Defaults that matter: - **"not running"** → `glance up`, then `glance logs`. - **Passkey prompt fails with a security error** → the phone is on a hostname the RP ID does not cover. Compare `glance status`'s `rp id` with the hostname in the phone's address bar. -- **Dashboard loads but shows nothing** → hooks are not firing. Check that the port in - `hooks/hooks.json` matches `~/.grok/glance/config.json`; `glance sync-hooks` fixes it. +- **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. - **Page says "run npm install && npm run build"** → the web bundle is missing; build it. ## What it deliberately does not do