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