Drop the hook-template scheme and other dead weight
hooks/hooks.json was generated from a template by scripts/gen-hooks.mjs. Once every
hook became a command hook that reads hook.secret from $GLANCE_HOME itself, the
template had exactly two placeholders left: {{HOOK_TOKEN}}, which nothing had ever
substituted into anything, and {{APPROVAL_TIMEOUT_SECS}}. Generating a whole file to
compute one number is not a good trade, so the number is now fixed at 125s in the
committed hooks.json and the coupling is enforced in code instead: the daemon clamps
approval.timeoutMs to APPROVAL_MAX_WAIT_MS (90s), which keeps the script inside its
own hook timeout no matter what a hand-edited config.json says. Losing that clamp is
what would actually hurt — a killed script never runs its fail-open path.
Also removed:
- `glance sync-hooks`, `npm run build:hooks`, and hookSecret({create}). The daemon is
the only thing that should ever mint the secret.
- The ?k= query-string carrier for the hook secret. It existed for hooks that cannot
set headers; there are none, and a secret in a URL lands in logs and shell history.
- Snapshot.now and SessionView.startedAt, which were written on every snapshot and
every persist and read by nobody.
- An unused crypto import.
Docs and the e2e suite follow. The suite's ~12 sync-hooks assertions become static
checks on the committed file, plus new ones that hooks.json, APPROVAL_HOOK_TIMEOUT_SECS
and APPROVAL_MAX_WAIT_MS still agree, and that ?k= is refused. 220 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6d34a17d9d
commit
d20eb9255c
@@ -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
@@ -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
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,6 @@ export const SESSION_COOKIE = "glance_session";
|
||||
export const CSRF_HEADER = "x-glance-csrf";
|
||||
/** Shared-secret header presented by the hook scripts on /hook/*. */
|
||||
export const HOOK_HEADER = "x-glance-hook";
|
||||
/** Query-string carrier for the same secret, for hooks that cannot set headers. */
|
||||
export const HOOK_QUERY_PARAM = "k";
|
||||
|
||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
@@ -82,6 +82,21 @@ export function ensureHome(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest the daemon may hold a tool call waiting for a tap.
|
||||
*
|
||||
* hooks/hooks.json gives the approval hook a fixed 125s timeout, and glance-approve.mjs keeps
|
||||
* 10s of that for itself. Waiting longer than this would get the script killed mid-wait, and a
|
||||
* killed script never runs its fail-open path — so a hand-edited config.json is clamped rather
|
||||
* than believed.
|
||||
*/
|
||||
export const APPROVAL_MAX_WAIT_MS = 90_000;
|
||||
|
||||
function clampApprovalWait(ms: number): number {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return DEFAULTS.approval.timeoutMs;
|
||||
return Math.min(ms, APPROVAL_MAX_WAIT_MS);
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
ensureHome();
|
||||
let stored: Partial<Config> = {};
|
||||
@@ -95,6 +110,7 @@ export function loadConfig(): Config {
|
||||
...stored,
|
||||
approval: { ...DEFAULTS.approval, ...(stored.approval ?? {}) },
|
||||
};
|
||||
merged.approval.timeoutMs = clampApprovalWait(merged.approval.timeoutMs);
|
||||
if (process.env.GLANCE_PORT) {
|
||||
const p = Number(process.env.GLANCE_PORT);
|
||||
if (Number.isFinite(p)) merged.port = p;
|
||||
|
||||
+6
-7
@@ -32,7 +32,6 @@ import {
|
||||
CSRF_HEADER,
|
||||
EnrollmentCodes,
|
||||
HOOK_HEADER,
|
||||
HOOK_QUERY_PARAM,
|
||||
RateLimiter,
|
||||
SESSION_COOKIE,
|
||||
buildSessionCookie,
|
||||
@@ -119,16 +118,16 @@ function isAdmin(req: http.IncomingMessage): boolean {
|
||||
* loopback, so without a check anyone on the tailnet could POST forged events into the
|
||||
* timeline and answer /hook/approve on your behalf. Two independent barriers:
|
||||
*
|
||||
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), presented as a header or,
|
||||
* for hook types that cannot set one, as `?k=`. Compared in constant time.
|
||||
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), sent as a header and
|
||||
* compared in constant time. Header only: a secret in a query string ends up in logs
|
||||
* and shell history, and every caller here is a local process that can set one.
|
||||
* 2. The request must not have been proxied. Tailscale stamps `x-forwarded-*` on anything
|
||||
* it tunnels, so their presence means the caller is not a local process — which no
|
||||
* real hook ever is. This keeps a leaked secret from being usable off-box.
|
||||
*/
|
||||
function isHookCaller(req: http.IncomingMessage, url: URL): boolean {
|
||||
function isHookCaller(req: http.IncomingMessage): boolean {
|
||||
if (header(req, "x-forwarded-for") || header(req, "x-forwarded-proto")) return false;
|
||||
const provided = header(req, HOOK_HEADER) ?? url.searchParams.get(HOOK_QUERY_PARAM);
|
||||
return sameSecret(provided, hookToken);
|
||||
return sameSecret(header(req, HOOK_HEADER), hookToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +184,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
||||
return;
|
||||
}
|
||||
// Before reading a body: an unauthenticated caller gets to spend nothing here.
|
||||
if (!isHookCaller(req, url)) {
|
||||
if (!isHookCaller(req)) {
|
||||
out.json(403, { error: "hook token required" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ export interface SessionView {
|
||||
badge: number;
|
||||
cwd: string;
|
||||
state: SessionState;
|
||||
startedAt: number;
|
||||
lastActivity: number;
|
||||
lastPrompt?: string;
|
||||
/** Tool calls in flight, oldest first — an agent can run several at once. */
|
||||
@@ -93,7 +92,6 @@ export interface ApprovalSettings {
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
now: number;
|
||||
version: string;
|
||||
sessions: SessionView[];
|
||||
events: GlanceEvent[];
|
||||
|
||||
@@ -126,7 +126,6 @@ export class GlanceState {
|
||||
badge: this.nextBadge++,
|
||||
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
|
||||
state: "idle",
|
||||
startedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
running: [],
|
||||
counts: { tools: 0, failures: 0, denials: 0 },
|
||||
@@ -397,7 +396,6 @@ export class GlanceState {
|
||||
.sort((a, b) => ATTENTION_RANK[a.state] - ATTENTION_RANK[b.state] || a.badge - b.badge);
|
||||
|
||||
return {
|
||||
now,
|
||||
version: VERSION,
|
||||
sessions,
|
||||
events: [...this.events].sort((a, b) => b.ts - a.ts || b.id - a.id),
|
||||
@@ -458,7 +456,6 @@ function restoreSession(raw: unknown): SessionView | null {
|
||||
badge: Math.max(1, Math.floor(s.badge)),
|
||||
cwd: typeof s.cwd === "string" ? s.cwd : "",
|
||||
state: s.state && s.state in ATTENTION_RANK ? s.state : "idle",
|
||||
startedAt: typeof s.startedAt === "number" ? s.startedAt : Date.now(),
|
||||
lastActivity: typeof s.lastActivity === "number" ? s.lastActivity : 0,
|
||||
lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined,
|
||||
// Nothing survives the restart: whatever reports the end of a tool call was talking to
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user