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
+13 -18
View File
@@ -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 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 the daemon serves the dashboard itself. `hooks/hooks.json` is checked in as-is — nothing about it
`hooks/hooks.template.json`, and creates `~/.grok/glance/hook.secret` (mode 0600) if it does not is generated or machine-specific. The shared secret the hook scripts authenticate with lives in
exist yet — the shared secret the hook scripts authenticate with. Neither the secret nor anything `~/.grok/glance/hook.secret` (mode 0600) and is created by the daemon on first start; it never
derived from it ends up in `hooks.json`. appears in `hooks.json`.
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so 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 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. | | 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. | | 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. | | 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 **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 | | `devices` | List enrolled devices |
| `revoke <id-prefix>` | Revoke a device | | `revoke <id-prefix>` | Revoke a device |
| `approval <off\|risky\|all>` | Set the approval policy | | `approval <off\|risky\|all>` | Set the approval policy |
| `sync-hooks` | Regenerate `hooks/hooks.json` from the template (after changing `config.json`) |
## Files and configuration ## 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_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 | | `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 Nothing in `hooks/hooks.json` is machine-specific: the hook scripts read `config.json` themselves,
changing it needs nothing but a daemon restart. Changing `approval.timeoutMs` does affect the so changing the port or `approval.timeoutMs` needs nothing but a daemon restart.
generated file: run `node bin/glance sync-hooks` afterwards so the approval hook's own timeout still
outlasts the wait.
## Security notes ## Security notes
@@ -308,9 +305,8 @@ stop working and must be enrolled again.
## Hook wiring ## Hook wiring
`hooks/hooks.json` subscribes to all 14 lifecycle events, and **is generated** — from `hooks/hooks.json` subscribes to all 14 lifecycle events and is a plain checked-in file — edit it
`hooks/hooks.template.json` by `scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on directly.
`node bin/glance sync-hooks`. Edit the template, not the output.
Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon 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** 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 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 `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 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 authenticate itself even if it could connect. This plugin shipped `http` hooks for a while, and the
URL is not `https://`, because the alternative is what this plugin shipped for a while: 13 passive result was 13 passive hooks failing validation silently on every event.
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 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 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 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 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 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 actually need a tap. Its `timeout` is a fixed 125s, and the daemon clamps its own wait to 90s
than hand-copied, which is the other thing `sync-hooks` refreshes. 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 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. deliberately denying — including when the daemon rejects their token.
+1 -19
View File
@@ -10,7 +10,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawn, spawnSync } from "node:child_process"; import { spawn } from "node:child_process";
import { import {
PLUGIN_ROOT, PLUGIN_ROOT,
baseUrl, baseUrl,
@@ -221,23 +221,6 @@ switch (cmd) {
break; 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": { case "logs": {
const file = path.join(glanceHome(), "daemon.log"); const file = path.join(glanceHome(), "daemon.log");
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
@@ -260,7 +243,6 @@ switch (cmd) {
glance devices list enrolled devices glance devices list enrolled devices
glance revoke <id-prefix> revoke a device glance revoke <id-prefix> revoke a device
glance approval <off|risky|all> remote approval policy glance approval <off|risky|all> remote approval policy
glance sync-hooks regenerate hooks/hooks.json from the template
glance logs tail the daemon log glance logs tail the daemon log
`); `);
} }
+4 -4
View File
@@ -11,7 +11,7 @@
*/ */
import { import {
approvalHookTimeoutSecs, APPROVAL_HOOK_TIMEOUT_SECS,
baseUrl, baseUrl,
envEnvelope, envEnvelope,
hookHeaders, hookHeaders,
@@ -36,10 +36,10 @@ function deny(reason) {
const payload = envEnvelope(await readStdinJson()); const payload = envEnvelope(await readStdinJson());
const cfg = readConfig(); const cfg = readConfig();
// Finish inside the hook timeout that scripts/gen-hooks.mjs wrote into hooks.json, with room // Finish inside the hook's own timeout from hooks.json, with room to spare: if Grok Build
// to spare: if Grok Build kills us first, the fail-open path below never gets to run. // kills us first, the fail-open path below never gets to run.
const waitMs = Math.min( const waitMs = Math.min(
approvalHookTimeoutSecs(cfg) * 1000 - 10_000, APPROVAL_HOOK_TIMEOUT_SECS * 1000 - 10_000,
Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000,
); );
+13 -35
View File
@@ -8,7 +8,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.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 * 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 * Only the daemon ever creates it. A hook must never be the thing that creates state, and a
* null when there is no secret yet. That is deliberate: a hook must never be the thing that * missing secret has to degrade to "no telemetry", not "no tool call" — so this returns null
* creates state, and a missing secret has to degrade to "no telemetry", not "no tool call". * and the callers carry on.
*/ */
export function hookSecret({ create = false } = {}) { export function hookSecret() {
const file = path.join(glanceHome(), "hook.secret");
for (let attempt = 0; attempt < 2; attempt++) {
try { try {
const existing = fs.readFileSync(file, "utf8").trim(); return fs.readFileSync(path.join(glanceHome(), "hook.secret"), "utf8").trim() || null;
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 { } catch {
return null; 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 * It bounds everything downstream: if the script outlives its hook timeout, Grok Build kills
* `timeout`, and glance-approve.mjs derives its own wait from it. They must agree — if the * it and the fail-open path never runs. So the daemon caps its own wait well inside it (see
* script outlives its hook timeout, Grok Build kills it and the fail-open path never runs. * 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()) { export const APPROVAL_HOOK_TIMEOUT_SECS = 125;
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. */ /** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
export async function readStdinJson() { export async function readStdinJson() {
+9 -19
View File
@@ -1,9 +1,6 @@
{ {
"_comment": [ "_comment": [
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by", "Every entry is a `command` hook, including the 13 passive recorders. That is not a style",
"`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", "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,", "(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", "then resolves the host and refuses the resolved address if it is private, link-local or",
@@ -14,26 +11,19 @@
"could connect.", "could connect.",
"", "",
"A command hook has none of those problems: it is a local process, so there is no URL to", "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", "validate, no proxy in the path, and it reads the shared secret out of $GLANCE_HOME itself.",
"start (~40ms) per event.", "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", "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,", "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.", "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:", "The gate's 125s timeout is the ceiling for the whole approval round trip. The daemon caps",
" APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.", "approval.timeoutMs at 90s against it, so the script always outlives its own wait and gets",
" HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that", "to fail open. Changing the number here means changing APPROVAL_HOOK_TIMEOUT_SECS in",
" cannot set a header: the daemon accepts it as a ?k= query", "bin/glance-lib.mjs and APPROVAL_MAX_WAIT_MS in server/src/config.ts to match."
" 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": { "hooks": {
"SessionStart": [ "SessionStart": [
-156
View File
@@ -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 }
]
}
]
}
}
+1 -2
View File
@@ -8,10 +8,9 @@
"node": ">=20" "node": ">=20"
}, },
"scripts": { "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:server": "tsc -p tsconfig.server.json",
"build:web": "tsc -p tsconfig.web.json && vite build", "build:web": "tsc -p tsconfig.web.json && vite build",
"build:hooks": "node scripts/gen-hooks.mjs",
"dev": "vite", "dev": "vite",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"glance": "node bin/glance" "glance": "node bin/glance"
-141
View File
@@ -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.",
);
}
-2
View File
@@ -7,8 +7,6 @@ export const SESSION_COOKIE = "glance_session";
export const CSRF_HEADER = "x-glance-csrf"; export const CSRF_HEADER = "x-glance-csrf";
/** Shared-secret header presented by the hook scripts on /hook/*. */ /** Shared-secret header presented by the hook scripts on /hook/*. */
export const HOOK_HEADER = "x-glance-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> { export function parseCookies(header: string | undefined): Record<string, string> {
const out: 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 { export function loadConfig(): Config {
ensureHome(); ensureHome();
let stored: Partial<Config> = {}; let stored: Partial<Config> = {};
@@ -95,6 +110,7 @@ export function loadConfig(): Config {
...stored, ...stored,
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) }, approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
}; };
merged.approval.timeoutMs = clampApprovalWait(merged.approval.timeoutMs);
if (process.env.GLANCE_PORT) { if (process.env.GLANCE_PORT) {
const p = Number(process.env.GLANCE_PORT); const p = Number(process.env.GLANCE_PORT);
if (Number.isFinite(p)) merged.port = p; if (Number.isFinite(p)) merged.port = p;
+6 -7
View File
@@ -32,7 +32,6 @@ import {
CSRF_HEADER, CSRF_HEADER,
EnrollmentCodes, EnrollmentCodes,
HOOK_HEADER, HOOK_HEADER,
HOOK_QUERY_PARAM,
RateLimiter, RateLimiter,
SESSION_COOKIE, SESSION_COOKIE,
buildSessionCookie, 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 * 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: * 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, * 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), sent as a header and
* for hook types that cannot set one, as `?k=`. Compared in constant time. * 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 * 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 * 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. * 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; 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(header(req, HOOK_HEADER), hookToken);
return sameSecret(provided, hookToken);
} }
/** /**
@@ -185,7 +184,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
return; return;
} }
// Before reading a body: an unauthenticated caller gets to spend nothing here. // 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" }); out.json(403, { error: "hook token required" });
return; return;
} }
-2
View File
@@ -59,7 +59,6 @@ export interface SessionView {
badge: number; badge: number;
cwd: string; cwd: string;
state: SessionState; state: SessionState;
startedAt: number;
lastActivity: number; lastActivity: number;
lastPrompt?: string; lastPrompt?: string;
/** Tool calls in flight, oldest first — an agent can run several at once. */ /** Tool calls in flight, oldest first — an agent can run several at once. */
@@ -93,7 +92,6 @@ export interface ApprovalSettings {
} }
export interface Snapshot { export interface Snapshot {
now: number;
version: string; version: string;
sessions: SessionView[]; sessions: SessionView[];
events: GlanceEvent[]; events: GlanceEvent[];
-3
View File
@@ -126,7 +126,6 @@ export class GlanceState {
badge: this.nextBadge++, badge: this.nextBadge++,
cwd: payload.workspaceRoot ?? payload.cwd ?? "", cwd: payload.workspaceRoot ?? payload.cwd ?? "",
state: "idle", state: "idle",
startedAt: Date.now(),
lastActivity: Date.now(), lastActivity: Date.now(),
running: [], running: [],
counts: { tools: 0, failures: 0, denials: 0 }, 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); .sort((a, b) => ATTENTION_RANK[a.state] - ATTENTION_RANK[b.state] || a.badge - b.badge);
return { return {
now,
version: VERSION, version: VERSION,
sessions, sessions,
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id), 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)), badge: Math.max(1, Math.floor(s.badge)),
cwd: typeof s.cwd === "string" ? s.cwd : "", cwd: typeof s.cwd === "string" ? s.cwd : "",
state: s.state && s.state in ATTENTION_RANK ? s.state : "idle", 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, lastActivity: typeof s.lastActivity === "number" ? s.lastActivity : 0,
lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined, lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined,
// Nothing survives the restart: whatever reports the end of a tool call was talking to // Nothing survives the restart: whatever reports the end of a tool call was talking to
+1 -3
View File
@@ -37,7 +37,6 @@ glance set-origin <https-url> # set the public origin and WebAuthn RP ID
glance devices # list enrolled devices glance devices # list enrolled devices
glance revoke <id-prefix> # revoke one glance revoke <id-prefix> # revoke one
glance approval <off|risky|all> # remote approve/deny policy glance approval <off|risky|all> # remote approve/deny policy
glance sync-hooks # regenerate hooks/hooks.json from hooks/hooks.template.json
``` ```
## Watching several agents ## 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 - **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 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 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 `hooks/hooks.json` exists and that the plugin is registered with Grok Build.
from the template.
- **Page says "run npm install && npm run build"** → the web bundle is missing; build it. - **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 - **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 environment where the hooks are not registered. A session appears on its next hook event; nothing
-2
View File
@@ -59,7 +59,6 @@ export interface SessionView {
badge: number; badge: number;
cwd: string; cwd: string;
state: SessionState; state: SessionState;
startedAt: number;
lastActivity: number; lastActivity: number;
lastPrompt?: string; lastPrompt?: string;
/** Tool calls in flight, oldest first — an agent can run several at once. */ /** Tool calls in flight, oldest first — an agent can run several at once. */
@@ -93,7 +92,6 @@ export interface ApprovalSettings {
} }
export interface Snapshot { export interface Snapshot {
now: number;
version: string; version: string;
sessions: SessionView[]; sessions: SessionView[];
events: GlanceEvent[]; events: GlanceEvent[];