Authenticate /hook/*, and make every hook a command hook
/hook/record and /hook/approve accepted anything that reached the port. That is not "loopback only": `tailscale serve` proxies tailnet traffic to 127.0.0.1, so anyone who could reach the tunnel could forge timeline events and answer approval prompts. Both endpoints now require a 32-byte secret from $GLANCE_HOME/hook.secret (0600, created once, never rotated so nothing in flight is 403'd mid-session), compared in constant time before the body is read, as an x-glance-hook header or a ?k= parameter. Requests carrying x-forwarded-* are refused outright: a local hook process never sends them and a tunnelled caller always does. The check applies to /hook/* only, so the dashboard is unaffected. While wiring that up: the 13 passive `type: "http"` hooks could never have worked. Grok Build's http runner rejects every scheme but https, then resolves the host and blocks private/link-local/CGNAT addresses (validate_hook_url + is_blocked_ip), so neither loopback-over-http nor *.ts.net (100.64/10) can be a hook target - and it sends no header but Content-Type, so such a hook could not authenticate anyway. They were failing validation silently on every event. All of them are now command hooks running bin/glance-record.mjs, which costs a Node start and can present the secret. hooks.json is generated from hooks/hooks.template.json by scripts/gen-hooks.mjs (npm run build, glance sync-hooks). It creates the secret, derives the approval hook's timeout from approval.timeoutMs instead of hand-copying 125, and refuses to write a hook that cannot fire: bad type, non-positive timeout, non-https http URL, missing bin/ script, or a leftover placeholder. A template that embeds the token makes the output 0600 with a warning. Fail-open is unchanged: a missing, stale or rejected secret degrades to "no telemetry", and glance-approve.mjs still allows on every error path. glance status warns when the on-disk secret no longer matches the daemon's. Validated with the e2e suite (190 checks, including no-token/wrong-token/ same-length-token 403s, ?k= acceptance, x-forwarded-* refusal, and the recorder's fail-open paths) and a clean npm run build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b3b6bf3f70
commit
5eec1940be
@@ -46,7 +46,10 @@ npm install && npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required;
|
The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required;
|
||||||
the daemon serves the dashboard itself.
|
the daemon serves the dashboard itself. It also generates `hooks/hooks.json` from
|
||||||
|
`hooks/hooks.template.json`, and creates `~/.grok/glance/hook.secret` (mode 0600) if it does not
|
||||||
|
exist yet — the shared secret the hook scripts authenticate with. Neither the secret nor anything
|
||||||
|
derived from it ends up in `hooks.json`.
|
||||||
|
|
||||||
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so
|
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
|
||||||
@@ -144,12 +147,13 @@ Defaults worth knowing:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. |
|
| 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 125s; raising this past that would just make the hook give up first. |
|
| Timeout | 90s | no — edit `config.json` | The hook's own timeout is derived from this (`+35s` of slack) when `hooks.json` is generated, so re-run `sync-hooks` after changing it. |
|
||||||
| Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. |
|
| 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
|
||||||
down, hook timeout, malformed response, port mismatch — the tool call proceeds. If you need calls
|
down, hook timeout, malformed response, port mismatch, a hook secret the daemon no longer
|
||||||
actually blocked, use Grok Build's own permission settings.
|
recognises — the tool call proceeds. If you need calls actually blocked, use Grok Build's own
|
||||||
|
permission settings.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
@@ -167,7 +171,7 @@ actually blocked, use Grok Build's own 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` | Rewrite hook URLs after changing the port |
|
| `sync-hooks` | Regenerate `hooks/hooks.json` from the template (after changing `config.json`) |
|
||||||
|
|
||||||
## Files and configuration
|
## Files and configuration
|
||||||
|
|
||||||
@@ -180,6 +184,7 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i
|
|||||||
| `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens |
|
| `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens |
|
||||||
| `secret.key` | 32-byte HMAC key used to sign session cookies |
|
| `secret.key` | 32-byte HMAC key used to sign session cookies |
|
||||||
| `admin.token` | Rotated every daemon start; authenticates the CLI |
|
| `admin.token` | Rotated every daemon start; authenticates the CLI |
|
||||||
|
| `hook.secret` | Shared secret the hook scripts present on `/hook/*`. Created once, mode 0600, never rotated — a rotation mid-session would 403 whatever was already in flight. Delete it and the daemon mints a new one on its next start; hooks then need that restart to agree again, which `glance status` will tell you about. |
|
||||||
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
|
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
|
||||||
| `daemon.log` | Daemon stdout/stderr |
|
| `daemon.log` | Daemon stdout/stderr |
|
||||||
|
|
||||||
@@ -192,8 +197,10 @@ instance without touching your real one:
|
|||||||
| `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) |
|
| `GLANCE_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 |
|
||||||
|
|
||||||
To change the port, edit `config.json`, then run `node bin/glance sync-hooks` so the hook URLs in
|
The port is not baked into `hooks/hooks.json` — the hook scripts read `config.json` themselves — so
|
||||||
`hooks/hooks.json` match. Restart the daemon afterwards.
|
changing it needs nothing but a daemon restart. Changing `approval.timeoutMs` does affect the
|
||||||
|
generated file: run `node bin/glance sync-hooks` afterwards so the approval hook's own timeout still
|
||||||
|
outlasts the wait.
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
@@ -202,13 +209,20 @@ its own. Three classes of caller:
|
|||||||
|
|
||||||
| Path | Caller | Authentication |
|
| Path | Caller | Authentication |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | none — loopback only |
|
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | shared secret from `hook.secret`, plus a refusal of any proxied request |
|
||||||
| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header |
|
| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header |
|
||||||
| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` |
|
| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` |
|
||||||
|
|
||||||
`/local/*` is token-gated rather than "is it from localhost", because `tailscale serve` proxies
|
None of the three trusts the source address, because `tailscale serve` proxies remote traffic to
|
||||||
remote traffic to `127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by
|
`127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by address alone. Without
|
||||||
address alone.
|
the hook secret, anyone who could reach the tunnel could forge timeline events and answer approval
|
||||||
|
prompts on your behalf; `/hook/*` compares the secret in constant time before it reads a body, and
|
||||||
|
additionally refuses any request carrying `x-forwarded-for` or `x-forwarded-proto`, which a local
|
||||||
|
hook process never sends and a tunnelled caller always does.
|
||||||
|
|
||||||
|
That refusal costs nothing, because no legitimate hook traffic comes through the tunnel: hooks are
|
||||||
|
local processes talking to loopback. It applies **only** to `/hook/*` — the dashboard arrives
|
||||||
|
through `tailscale serve` with those headers on every request and is unaffected.
|
||||||
|
|
||||||
**Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request
|
**Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request
|
||||||
arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions
|
arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions
|
||||||
@@ -256,21 +270,36 @@ stop working and must be enrolled again.
|
|||||||
|
|
||||||
## Hook wiring
|
## Hook wiring
|
||||||
|
|
||||||
`hooks/hooks.json` subscribes to all 14 lifecycle events. Passive events use `type: "http"`: they
|
`hooks/hooks.json` subscribes to all 14 lifecycle events, and **is generated** — from
|
||||||
POST straight into the daemon with no process spawn, so they cost close to nothing per tool call
|
`hooks/hooks.template.json` by `scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on
|
||||||
and quietly do nothing when the daemon is down.
|
`node bin/glance sync-hooks`. Edit the template, not the output.
|
||||||
|
|
||||||
Two exceptions:
|
Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon
|
||||||
|
by any route. Grok Build's http runner rejects every scheme but `https`, then **resolves the host**
|
||||||
|
and refuses the resolved address if it is private, link-local or CGNAT
|
||||||
|
(`xai-grok-hooks/src/runner/http.rs`, `validate_hook_url` + `is_blocked_ip`). Plain http on loopback
|
||||||
|
fails the scheme check; the tailnet fails the address check, because `*.ts.net` resolves into
|
||||||
|
`100.64/10` (and `fd7a::/48`, inside the blocked `fc00::/7`). On top of that the runner sends no
|
||||||
|
request header but `Content-Type`, with no configuration surface for one, so such a hook could not
|
||||||
|
authenticate itself even if it could connect. The generator refuses to emit an `http` handler whose
|
||||||
|
URL is not `https://`, because the alternative is what this plugin shipped for a while: 13 passive
|
||||||
|
hooks that failed validation silently on every event.
|
||||||
|
|
||||||
|
A command hook has none of those problems. It is a local process, so no URL is validated, nothing
|
||||||
|
traverses the tunnel, and it can present the shared secret — hook traffic goes straight to
|
||||||
|
`http://127.0.0.1:8791` and never leaves the machine. So each observed event runs
|
||||||
|
`bin/glance-record.mjs`, which costs a Node start (~40 ms) and POSTs one event. Two entries differ:
|
||||||
|
|
||||||
- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon.
|
- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon.
|
||||||
- `PreToolUse` is wired **twice** — an `http` entry that records every call for the timeline, and a
|
- `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second entry matching
|
||||||
`command` entry matching only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs
|
only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs `bin/glance-approve.mjs`. PreToolUse
|
||||||
`bin/glance-approve.mjs`. PreToolUse is the only blocking event, and a command hook is the only
|
is the only blocking event, and a command hook is the only documented way to return a deny
|
||||||
documented way to return a deny decision, so the gate has to be a spawned process; keeping the
|
decision; keeping the match narrow means the gate's cost is paid only for calls that could
|
||||||
match narrow means the cost is paid only for calls that could actually need a tap.
|
actually need a tap. Its `timeout` is derived from `approval.timeoutMs` at generation time rather
|
||||||
|
than hand-copied, which is the other thing `sync-hooks` refreshes.
|
||||||
|
|
||||||
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
|
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
|
||||||
deliberately denying.
|
deliberately denying — including when the daemon rejects their token.
|
||||||
|
|
||||||
## Deliberately omitted
|
## Deliberately omitted
|
||||||
|
|
||||||
|
|||||||
+20
-17
@@ -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 } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
PLUGIN_ROOT,
|
PLUGIN_ROOT,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -124,6 +124,12 @@ switch (cmd) {
|
|||||||
console.log(` watchers : ${s.watchers}`);
|
console.log(` watchers : ${s.watchers}`);
|
||||||
console.log(` sessions : ${s.sessions}`);
|
console.log(` sessions : ${s.sessions}`);
|
||||||
console.log(` events kept : ${s.events}`);
|
console.log(` events kept : ${s.events}`);
|
||||||
|
if (s.hookAuthOk === false) {
|
||||||
|
console.log(
|
||||||
|
"\n ! hook auth mismatch: $GLANCE_HOME/hook.secret no longer matches what the daemon" +
|
||||||
|
"\n loaded, so events are being dropped. Restart it: glance stop && glance up",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
|
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -200,22 +206,19 @@ switch (cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "sync-hooks": {
|
case "sync-hooks": {
|
||||||
const file = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
|
// hooks.json is generated, not edited: regenerate it from hooks/hooks.template.json.
|
||||||
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
// The port is not baked into it any more — the hook scripts read config.json themselves —
|
||||||
let changed = 0;
|
// so the thing this actually refreshes is the approval gate's timeout, plus the hook
|
||||||
for (const groups of Object.values(doc.hooks ?? {})) {
|
// secret if it has gone missing.
|
||||||
for (const group of groups) {
|
const { status } = spawnSync(
|
||||||
for (const h of group.hooks ?? []) {
|
process.execPath,
|
||||||
if (h.type === "http" && typeof h.url === "string") {
|
[path.join(PLUGIN_ROOT, "scripts", "gen-hooks.mjs")],
|
||||||
const next = h.url.replace(/127\.0\.0\.1:\d+/, `127.0.0.1:${cfg.port}`);
|
{ stdio: "inherit" },
|
||||||
if (next !== h.url) changed++;
|
);
|
||||||
h.url = next;
|
if (status !== 0) process.exit(status ?? 1);
|
||||||
|
if (await isDaemonUp(cfg)) {
|
||||||
|
console.log("restart the daemon to pick up config changes: glance stop && glance up");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
||||||
console.log(`rewrote ${changed} hook url(s) to port ${cfg.port}`);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +244,7 @@ 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 rewrite hook urls after a port change
|
glance sync-hooks regenerate hooks/hooks.json from the template
|
||||||
glance logs tail the daemon log
|
glance logs tail the daemon log
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-4
@@ -10,7 +10,15 @@
|
|||||||
* call Grok was already about to make. This hook can never introduce a new command.
|
* call Grok was already about to make. This hook can never introduce a new command.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { baseUrl, envEnvelope, postJson, readConfig, readStdinJson } from "./glance-lib.mjs";
|
import {
|
||||||
|
approvalHookTimeoutSecs,
|
||||||
|
baseUrl,
|
||||||
|
envEnvelope,
|
||||||
|
hookHeaders,
|
||||||
|
postJson,
|
||||||
|
readConfig,
|
||||||
|
readStdinJson,
|
||||||
|
} from "./glance-lib.mjs";
|
||||||
|
|
||||||
function allow() {
|
function allow() {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -28,11 +36,20 @@ function deny(reason) {
|
|||||||
const payload = envEnvelope(await readStdinJson());
|
const payload = envEnvelope(await readStdinJson());
|
||||||
const cfg = readConfig();
|
const cfg = readConfig();
|
||||||
|
|
||||||
// Stay inside the hook timeout declared in hooks/hooks.json (125s).
|
// Finish inside the hook timeout that scripts/gen-hooks.mjs wrote into hooks.json, with room
|
||||||
const waitMs = Math.min(115_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000);
|
// to spare: if Grok Build kills us first, the fail-open path below never gets to run.
|
||||||
|
const waitMs = Math.min(
|
||||||
|
approvalHookTimeoutSecs(cfg) * 1000 - 10_000,
|
||||||
|
Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs);
|
const { data } = await postJson(
|
||||||
|
`${baseUrl(cfg)}/hook/approve`,
|
||||||
|
payload,
|
||||||
|
waitMs,
|
||||||
|
hookHeaders(),
|
||||||
|
);
|
||||||
if (data && data.decision === "deny") deny(data.reason);
|
if (data && data.decision === "deny") deny(data.reason);
|
||||||
allow();
|
allow();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+59
-2
@@ -8,6 +8,7 @@
|
|||||||
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), "../..");
|
||||||
@@ -43,6 +44,62 @@ export function baseUrl(cfg = readConfig()) {
|
|||||||
return `http://127.0.0.1:${cfg.port}`;
|
return `http://127.0.0.1:${cfg.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header the daemon expects on /hook/*; kept in step with server/src/auth.ts. */
|
||||||
|
export const HOOK_HEADER = "x-glance-hook";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a
|
||||||
|
* regenerated secret is picked up without touching hooks.json.
|
||||||
|
*
|
||||||
|
* `create` is used by the build-time generator; the hook scripts pass false and simply get
|
||||||
|
* null when there is no secret yet. That is deliberate: a hook must never be the thing that
|
||||||
|
* creates state, and a missing secret has to degrade to "no telemetry", not "no tool call".
|
||||||
|
*/
|
||||||
|
export function hookSecret({ create = false } = {}) {
|
||||||
|
const file = path.join(glanceHome(), "hook.secret");
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const existing = fs.readFileSync(file, "utf8").trim();
|
||||||
|
if (existing) return existing;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
if (!create) return null;
|
||||||
|
fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 });
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
|
try {
|
||||||
|
// Exclusive: if the daemon created one a millisecond ago, read theirs instead.
|
||||||
|
fs.writeFileSync(file, token + "\n", { mode: 0o600, flag: "wx" });
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
/* lost the race; loop re-reads */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(file, "utf8").trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hookHeaders() {
|
||||||
|
const token = hookSecret();
|
||||||
|
return token ? { [HOOK_HEADER]: token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the PreToolUse approval hook is allowed to run, in seconds.
|
||||||
|
*
|
||||||
|
* One formula, two consumers: scripts/gen-hooks.mjs writes it into hooks.json as the hook's
|
||||||
|
* `timeout`, and glance-approve.mjs derives its own wait from it. They must agree — if the
|
||||||
|
* script outlives its hook timeout, Grok Build kills it and the fail-open path never runs.
|
||||||
|
*/
|
||||||
|
export function approvalHookTimeoutSecs(cfg = readConfig()) {
|
||||||
|
const ms = Number(cfg.approval?.timeoutMs ?? 90_000);
|
||||||
|
const base = Number.isFinite(ms) && ms > 0 ? ms : 90_000;
|
||||||
|
return Math.ceil(base / 1000) + 35;
|
||||||
|
}
|
||||||
|
|
||||||
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
|
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
|
||||||
export async function readStdinJson() {
|
export async function readStdinJson() {
|
||||||
if (process.stdin.isTTY) return {};
|
if (process.stdin.isTTY) return {};
|
||||||
@@ -77,10 +134,10 @@ export function envEnvelope(payload) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postJson(url, body, timeoutMs) {
|
export async function postJson(url, body, timeoutMs, extraHeaders = {}) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json", ...extraHeaders },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Passive recorder hook: POST one lifecycle event into the daemon and get out of the way.
|
||||||
|
*
|
||||||
|
* Wired to every observed event. This used to be a `type: "http"` hook — no process spawn at
|
||||||
|
* all — until it turned out that Grok Build's http runner validates the URL against its SSRF
|
||||||
|
* rules and rejects any scheme that is not https (crates/codegen/xai-grok-hooks/src/runner/
|
||||||
|
* http.rs, `validate_hook_url`). The daemon speaks plain http on loopback, so an http hook
|
||||||
|
* could never reach it: those hooks were failing validation, silently, on every event.
|
||||||
|
*
|
||||||
|
* A command hook costs a Node start (~40ms) per event, and buys back the ability to send an
|
||||||
|
* authentication header, which the http runner has no config surface for.
|
||||||
|
*
|
||||||
|
* Always exits 0. A dashboard must never be the reason a tool call fails.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
baseUrl,
|
||||||
|
envEnvelope,
|
||||||
|
hookHeaders,
|
||||||
|
postJson,
|
||||||
|
readConfig,
|
||||||
|
readStdinJson,
|
||||||
|
} from "./glance-lib.mjs";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = envEnvelope(await readStdinJson());
|
||||||
|
const cfg = readConfig();
|
||||||
|
// Short: if the daemon is not listening this must fail fast, not hold up the tool call.
|
||||||
|
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2000, hookHeaders());
|
||||||
|
} catch {
|
||||||
|
// Daemon down, no secret yet, malformed payload — all the same answer: carry on.
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
+4
-1
@@ -14,6 +14,7 @@ import {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
envEnvelope,
|
envEnvelope,
|
||||||
glanceHome,
|
glanceHome,
|
||||||
|
hookHeaders,
|
||||||
isDaemonUp,
|
isDaemonUp,
|
||||||
postJson,
|
postJson,
|
||||||
readConfig,
|
readConfig,
|
||||||
@@ -59,7 +60,9 @@ const cfg = readConfig();
|
|||||||
try {
|
try {
|
||||||
const up = await ensureDaemon(cfg);
|
const up = await ensureDaemon(cfg);
|
||||||
if (up) {
|
if (up) {
|
||||||
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500);
|
// hookHeaders() is read here, not at import time: on a first-ever run the daemon we just
|
||||||
|
// spawned is what created hook.secret.
|
||||||
|
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500, hookHeaders());
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fail open, always.
|
// Fail open, always.
|
||||||
|
|||||||
+73
-24
@@ -1,14 +1,39 @@
|
|||||||
{
|
{
|
||||||
"_comment": [
|
"_comment": [
|
||||||
"grok-glance hook wiring.",
|
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by",
|
||||||
"Passive events use type=http: they POST straight into the daemon with no process spawn,",
|
"`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.",
|
||||||
"so they cost ~nothing per tool call and fail open if the daemon is not running.",
|
"",
|
||||||
"SessionStart uses type=command because it is what boots the daemon.",
|
"Everything is a `command` hook, including the 13 passive recorders. That is not a style",
|
||||||
"PreToolUse is wired twice on purpose: an http entry records every tool call for the",
|
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
|
||||||
"timeline, and a command entry gates only risky tools, because only a command hook has a",
|
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
|
||||||
"documented way to return a deny decision.",
|
"then resolves the host and refuses the resolved address if it is private, link-local or",
|
||||||
"If you change the port in ~/.grok/glance/config.json, run `glance sync-hooks` to rewrite",
|
"CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves",
|
||||||
"the URLs below, or edit them by hand."
|
"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": {
|
"hooks": {
|
||||||
"SessionStart": [
|
"SessionStart": [
|
||||||
@@ -26,9 +51,9 @@
|
|||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "http",
|
"type": "command",
|
||||||
"url": "http://127.0.0.1:8791/hook/record",
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"",
|
||||||
"timeout": 3
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -45,62 +70,86 @@
|
|||||||
],
|
],
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PostToolUseFailure": [
|
"PostToolUseFailure": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"UserPromptSubmit": [
|
"UserPromptSubmit": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PermissionDenied": [
|
"PermissionDenied": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Notification": [
|
"Notification": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Stop": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"StopFailure": [
|
"StopFailure": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SubagentStart": [
|
"SubagentStart": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SubagentStop": [
|
"SubagentStop": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PreCompact": [
|
"PreCompact": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PostCompact": [
|
"PostCompact": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SessionEnd": [
|
"SessionEnd": [
|
||||||
{
|
{
|
||||||
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by",
|
||||||
|
"`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.",
|
||||||
|
"",
|
||||||
|
"Everything is a `command` hook, including the 13 passive recorders. That is not a style",
|
||||||
|
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
|
||||||
|
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
|
||||||
|
"then resolves the host and refuses the resolved address if it is private, link-local or",
|
||||||
|
"CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves",
|
||||||
|
"into 100.64/10 (and fd7a::/48, inside the blocked fc00::/7). Pointing a hook at the public",
|
||||||
|
"https origin therefore fails upstream, before a request is ever sent. The runner also sends",
|
||||||
|
"no request header but Content-Type, so such a hook could not authenticate itself even if it",
|
||||||
|
"could connect.",
|
||||||
|
"",
|
||||||
|
"A command hook has none of those problems: it is a local process, so there is no URL to",
|
||||||
|
"validate, no proxy in the path, and it can present the shared secret. It costs one Node",
|
||||||
|
"start (~40ms) per event.",
|
||||||
|
"",
|
||||||
|
"SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records",
|
||||||
|
"every call for the timeline, and a second, narrowly matched entry runs the approval gate,",
|
||||||
|
"because PreToolUse is the only blocking event and only a command hook can return a deny.",
|
||||||
|
"",
|
||||||
|
"Placeholders, written in the template as a name wrapped in double braces:",
|
||||||
|
" APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.",
|
||||||
|
" HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that",
|
||||||
|
" cannot set a header: the daemon accepts it as a ?k= query",
|
||||||
|
" parameter too. Nothing below uses it and no http hook can (see",
|
||||||
|
" above); it stays because the daemon's ?k= path is real. Note the",
|
||||||
|
" daemon also refuses any /hook/* request carrying x-forwarded-*,",
|
||||||
|
" so a reverse-proxied transport is out as well. Using this",
|
||||||
|
" placeholder makes the generated hooks.json secret-bearing, so",
|
||||||
|
" gen-hooks writes it 0600 and it must not be committed.",
|
||||||
|
"",
|
||||||
|
"Do not spell those names with their braces anywhere in this comment block: the comment is",
|
||||||
|
"copied verbatim into hooks.json, and substitution would happily expand it there too."
|
||||||
|
],
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"",
|
||||||
|
"timeout": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"",
|
||||||
|
"timeout": "{{APPROVAL_TIMEOUT_SECS}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUseFailure": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PermissionDenied": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"StopFailure": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -8,9 +8,10 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:server && npm run build:web",
|
"build": "npm run build:server && npm run build:web && npm run build:hooks",
|
||||||
"build:server": "tsc -p tsconfig.server.json",
|
"build: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"
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generate hooks/hooks.json from hooks/hooks.template.json.
|
||||||
|
*
|
||||||
|
* Runs as part of `npm run build`, and again on `glance sync-hooks`. Two jobs:
|
||||||
|
*
|
||||||
|
* 1. Make sure the /hook/* shared secret exists (mode 0600, in $GLANCE_HOME). The daemon
|
||||||
|
* requires it; without it the hook scripts are just anonymous POSTs, which is what this
|
||||||
|
* whole mechanism exists to stop.
|
||||||
|
* 2. Substitute the placeholders the template declares, so values that are really derived
|
||||||
|
* from config.json - the approval hook's timeout above all - stop being hand-copied
|
||||||
|
* constants that drift.
|
||||||
|
*
|
||||||
|
* It also refuses to emit a hook that cannot work. An `http` handler pointed at a non-https
|
||||||
|
* URL is the specific mistake that made every passive hook in this plugin a no-op for a
|
||||||
|
* while: Grok Build's http runner puts every URL through SSRF validation and rejects any
|
||||||
|
* other scheme outright (xai-grok-hooks/src/runner/http.rs, `validate_hook_url`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { PLUGIN_ROOT, approvalHookTimeoutSecs, hookSecret, readConfig } from "../bin/glance-lib.mjs";
|
||||||
|
|
||||||
|
const TEMPLATE = path.join(PLUGIN_ROOT, "hooks", "hooks.template.json");
|
||||||
|
const OUTPUT = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
|
||||||
|
|
||||||
|
const cfg = readConfig();
|
||||||
|
const token = hookSecret({ create: true });
|
||||||
|
if (!token) {
|
||||||
|
console.error(`gen-hooks: could not create the hook secret in ${process.env.GLANCE_HOME ?? "~/.grok/glance"}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSecs = approvalHookTimeoutSecs(cfg);
|
||||||
|
|
||||||
|
let text = fs.readFileSync(TEMPLATE, "utf8");
|
||||||
|
|
||||||
|
// The quoted form first, so a JSON-valid template can carry a value that must end up numeric.
|
||||||
|
text = text
|
||||||
|
.split(`"{{APPROVAL_TIMEOUT_SECS}}"`)
|
||||||
|
.join(String(timeoutSecs))
|
||||||
|
.split("{{APPROVAL_TIMEOUT_SECS}}")
|
||||||
|
.join(String(timeoutSecs));
|
||||||
|
|
||||||
|
const embedsToken = text.includes("{{HOOK_TOKEN}}");
|
||||||
|
text = text.split("{{HOOK_TOKEN}}").join(token);
|
||||||
|
|
||||||
|
// Trust the emitted bytes, not the placeholder: a template that mentions the placeholder in a
|
||||||
|
// comment would otherwise ship the real secret in a world-readable file. (It did once.)
|
||||||
|
const tokenIsInOutput = text.includes(token);
|
||||||
|
if (tokenIsInOutput && !embedsToken) {
|
||||||
|
console.error("gen-hooks: the hook secret leaked into hooks.json from somewhere unexpected");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftover = text.match(/\{\{[A-Z_]+\}\}/);
|
||||||
|
if (leftover) {
|
||||||
|
console.error(`gen-hooks: unknown placeholder ${leftover[0]} in hooks.template.json`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ validate */
|
||||||
|
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`gen-hooks: template did not produce valid JSON: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
let handlers = 0;
|
||||||
|
|
||||||
|
for (const [event, groups] of Object.entries(doc.hooks ?? {})) {
|
||||||
|
if (!Array.isArray(groups)) {
|
||||||
|
problems.push(`${event}: expected an array of matcher groups`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const h of group.hooks ?? []) {
|
||||||
|
handlers++;
|
||||||
|
const where = `${event} -> ${h.command ?? h.url ?? "(no target)"}`;
|
||||||
|
if (h.type !== "command" && h.type !== "http") {
|
||||||
|
problems.push(`${where}: type must be "command" or "http", got ${JSON.stringify(h.type)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0) {
|
||||||
|
problems.push(`${where}: timeout must be a positive number of seconds`);
|
||||||
|
}
|
||||||
|
if (h.type === "http") {
|
||||||
|
// The runner rejects every scheme but https, and treats RFC1918 / CGNAT / link-local
|
||||||
|
// targets as SSRF. That rules out both loopback-over-http and Tailscale's 100.64/10.
|
||||||
|
if (!/^https:\/\//.test(h.url ?? "")) {
|
||||||
|
problems.push(
|
||||||
|
`${where}: http handlers must use an https:// URL - Grok Build's SSRF check ` +
|
||||||
|
`rejects anything else, so this hook would never fire`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const script = /bin\/([A-Za-z0-9._-]+)/.exec(h.command ?? "");
|
||||||
|
if (!script) {
|
||||||
|
problems.push(`${where}: could not tell which script this command runs`);
|
||||||
|
} else if (!fs.existsSync(path.join(PLUGIN_ROOT, "bin", script[1]))) {
|
||||||
|
problems.push(`${where}: bin/${script[1]} does not exist`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length) {
|
||||||
|
console.error("gen-hooks: refusing to write hooks.json\n");
|
||||||
|
for (const p of problems) console.error(` - ${p}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- write */
|
||||||
|
|
||||||
|
const mode = tokenIsInOutput ? 0o600 : 0o644;
|
||||||
|
const previous = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, "utf8") : null;
|
||||||
|
|
||||||
|
if (previous === text) {
|
||||||
|
// Leave the mtime alone: a no-op build should not look like a change.
|
||||||
|
fs.chmodSync(OUTPUT, mode);
|
||||||
|
console.log(`hooks.json already current (${handlers} handlers, approval timeout ${timeoutSecs}s)`);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(OUTPUT, text, { mode });
|
||||||
|
fs.chmodSync(OUTPUT, mode);
|
||||||
|
console.log(
|
||||||
|
`wrote hooks/hooks.json - ${handlers} handlers, approval timeout ${timeoutSecs}s` +
|
||||||
|
(previous === null ? " (new file)" : ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenIsInOutput) {
|
||||||
|
console.warn(
|
||||||
|
"gen-hooks: hooks.json now contains the hook secret (via the HOOK_TOKEN placeholder); it is " +
|
||||||
|
"mode 0600 and must not be committed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import type { IncomingMessage } from "node:http";
|
|||||||
|
|
||||||
export const SESSION_COOKIE = "glance_session";
|
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/*. */
|
||||||
|
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> = {};
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export const paths = {
|
|||||||
get adminToken() {
|
get adminToken() {
|
||||||
return path.join(glanceHome(), "admin.token");
|
return path.join(glanceHome(), "admin.token");
|
||||||
},
|
},
|
||||||
|
get hookSecret() {
|
||||||
|
return path.join(glanceHome(), "hook.secret");
|
||||||
|
},
|
||||||
get events() {
|
get events() {
|
||||||
return path.join(glanceHome(), "events.jsonl");
|
return path.join(glanceHome(), "events.jsonl");
|
||||||
},
|
},
|
||||||
|
|||||||
+44
-6
@@ -3,10 +3,13 @@
|
|||||||
*
|
*
|
||||||
* One small http server with three kinds of caller:
|
* One small http server with three kinds of caller:
|
||||||
*
|
*
|
||||||
* /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one.
|
* /hook/* the plugin's hook scripts, gated by the shared secret in hook.secret.
|
||||||
* /api/* the web app, gated by a passkey-backed cookie session.
|
* /api/* the web app, gated by a passkey-backed cookie session.
|
||||||
* /local/* the `glance` CLI, gated by a rotating admin token on disk.
|
* /local/* the `glance` CLI, gated by a rotating admin token on disk.
|
||||||
*
|
*
|
||||||
|
* None of the three trusts the source address: `tailscale serve` proxies tailnet traffic to
|
||||||
|
* 127.0.0.1, so every caller looks local.
|
||||||
|
*
|
||||||
* Everything the hooks touch is written to fail open: if this process is confused, wedged, or
|
* Everything the hooks touch is written to fail open: if this process is confused, wedged, or
|
||||||
* gone, Grok Build keeps working.
|
* gone, Grok Build keeps working.
|
||||||
*/
|
*/
|
||||||
@@ -28,6 +31,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
CSRF_HEADER,
|
CSRF_HEADER,
|
||||||
EnrollmentCodes,
|
EnrollmentCodes,
|
||||||
|
HOOK_HEADER,
|
||||||
|
HOOK_QUERY_PARAM,
|
||||||
RateLimiter,
|
RateLimiter,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
buildSessionCookie,
|
buildSessionCookie,
|
||||||
@@ -47,7 +52,9 @@ import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js";
|
|||||||
import {
|
import {
|
||||||
destroyAuthSession,
|
destroyAuthSession,
|
||||||
deviceList,
|
deviceList,
|
||||||
|
hookSecret,
|
||||||
lookupAuthSession,
|
lookupAuthSession,
|
||||||
|
readHookSecretFromDisk,
|
||||||
revokeCredentials,
|
revokeCredentials,
|
||||||
rotateAdminToken,
|
rotateAdminToken,
|
||||||
sessionSecret,
|
sessionSecret,
|
||||||
@@ -60,6 +67,7 @@ ensureHome();
|
|||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const secret = sessionSecret();
|
const secret = sessionSecret();
|
||||||
const adminToken = rotateAdminToken();
|
const adminToken = rotateAdminToken();
|
||||||
|
const hookToken = hookSecret();
|
||||||
const webauthn = new WebAuthnService(cfg);
|
const webauthn = new WebAuthnService(cfg);
|
||||||
const codes = new EnrollmentCodes();
|
const codes = new EnrollmentCodes();
|
||||||
|
|
||||||
@@ -93,14 +101,36 @@ function currentSession(req: http.IncomingMessage): Session | null {
|
|||||||
return { token, credentialId: record.credentialId, label: record.label };
|
return { token, credentialId: record.credentialId, label: record.label };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAdmin(req: http.IncomingMessage): boolean {
|
function sameSecret(provided: string | undefined | null, expected: string): boolean {
|
||||||
const provided = header(req, "x-glance-admin");
|
|
||||||
if (!provided) return false;
|
if (!provided) return false;
|
||||||
const a = Buffer.from(provided);
|
const a = Buffer.from(provided);
|
||||||
const b = Buffer.from(adminToken);
|
const b = Buffer.from(expected);
|
||||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAdmin(req: http.IncomingMessage): boolean {
|
||||||
|
return sameSecret(header(req, "x-glance-admin"), adminToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this really one of our hook scripts?
|
||||||
|
*
|
||||||
|
* "It came from 127.0.0.1" proves nothing: `tailscale serve` proxies tailnet traffic to
|
||||||
|
* loopback, so without a check anyone on the tailnet could POST forged events into the
|
||||||
|
* timeline and answer /hook/approve on your behalf. Two independent barriers:
|
||||||
|
*
|
||||||
|
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), presented as a header or,
|
||||||
|
* for hook types that cannot set one, as `?k=`. Compared in constant time.
|
||||||
|
* 2. The request must not have been proxied. Tailscale stamps `x-forwarded-*` on anything
|
||||||
|
* it tunnels, so their presence means the caller is not a local process — which no
|
||||||
|
* real hook ever is. This keeps a leaked secret from being usable off-box.
|
||||||
|
*/
|
||||||
|
function isHookCaller(req: http.IncomingMessage, url: URL): boolean {
|
||||||
|
if (header(req, "x-forwarded-for") || header(req, "x-forwarded-proto")) return false;
|
||||||
|
const provided = header(req, HOOK_HEADER) ?? url.searchParams.get(HOOK_QUERY_PARAM);
|
||||||
|
return sameSecret(provided, hookToken);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `application/json` is not a CORS-safelisted content type, so requiring it exactly means a
|
* `application/json` is not a CORS-safelisted content type, so requiring it exactly means a
|
||||||
* hostile page cannot post here without a preflight we never answer. The custom header on
|
* hostile page cannot post here without a preflight we never answer. The custom header on
|
||||||
@@ -154,6 +184,11 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
|||||||
out.json(405, { error: "post json" });
|
out.json(405, { error: "post json" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Before reading a body: an unauthenticated caller gets to spend nothing here.
|
||||||
|
if (!isHookCaller(req, url)) {
|
||||||
|
out.json(403, { error: "hook token required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = ((await readJson<HookPayload>(req)) ?? {}) as HookPayload;
|
const payload = ((await readJson<HookPayload>(req)) ?? {}) as HookPayload;
|
||||||
|
|
||||||
if (p === "/hook/record") {
|
if (p === "/hook/record") {
|
||||||
@@ -163,8 +198,8 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (p === "/hook/approve") {
|
if (p === "/hook/approve") {
|
||||||
// Note: this deliberately does not ingest an event. The PreToolUse http hook already
|
// Note: this deliberately does not ingest an event. The PreToolUse recording hook
|
||||||
// recorded the tool call; recording it here too would double every entry.
|
// already logged the tool call; recording it here too would double every entry.
|
||||||
const decision = await broker.request(payload);
|
const decision = await broker.request(payload);
|
||||||
out.json(200, decision);
|
out.json(200, decision);
|
||||||
return;
|
return;
|
||||||
@@ -194,6 +229,9 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
|||||||
events: state.eventCount,
|
events: state.eventCount,
|
||||||
webBuilt: webBuildExists(),
|
webBuilt: webBuildExists(),
|
||||||
home: paths.home,
|
home: paths.home,
|
||||||
|
// Would a hook script authenticate right now? The daemon holds the token it read at
|
||||||
|
// startup; if the file has since changed or gone, recording is silently dropping.
|
||||||
|
hookAuthOk: sameSecret(readHookSecretFromDisk(), hookToken),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,47 @@ export function rotateAdminToken(): string {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token the hook scripts present on /hook/*. Unlike `admin.token` this is *not* rotated on
|
||||||
|
* every start: hook scripts are separate short-lived processes that read the file per
|
||||||
|
* invocation, and a rotation mid-session would 403 whatever was already in flight.
|
||||||
|
*
|
||||||
|
* It exists because `tailscale serve` proxies tailnet traffic to 127.0.0.1, so "the request
|
||||||
|
* came from loopback" says nothing about who sent it. Without this, anyone on the tailnet
|
||||||
|
* could forge timeline events and answer approval prompts.
|
||||||
|
*
|
||||||
|
* Created exclusively (`wx`) so two hooks racing on a fresh home cannot end up with
|
||||||
|
* different values — the loser re-reads the winner's file.
|
||||||
|
*/
|
||||||
|
export function hookSecret(): string {
|
||||||
|
ensureHome();
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
const existing = fs.readFileSync(paths.hookSecret, "utf8").trim();
|
||||||
|
if (existing) return existing;
|
||||||
|
} catch {
|
||||||
|
/* create below */
|
||||||
|
}
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(paths.hookSecret, token + "\n", { mode: 0o600, flag: "wx" });
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
// Lost the race (or the file appeared between the read and the write): read it back.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fs.readFileSync(paths.hookSecret, "utf8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatever is on disk right now, for diagnostics. Never creates the file. */
|
||||||
|
export function readHookSecretFromDisk(): string | null {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(paths.hookSecret, "utf8").trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------- credentials */
|
/* -------------------------------------------------------------- credentials */
|
||||||
|
|
||||||
export function listCredentials(): StoredCredential[] {
|
export function listCredentials(): StoredCredential[] {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ 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 # rewrite hook URLs after changing the port
|
glance sync-hooks # regenerate hooks/hooks.json from hooks/hooks.template.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting it onto a phone
|
## Getting it onto a phone
|
||||||
@@ -63,8 +63,8 @@ and wait for a tap on the phone. Defaults that matter:
|
|||||||
- Nothing waits unless a phone is actually watching the dashboard (`requireWatcher`).
|
- Nothing waits unless a phone is actually watching the dashboard (`requireWatcher`).
|
||||||
- If nobody answers within 90s the call is **allowed**, not denied. Flip that on the phone's
|
- If nobody answers within 90s the call is **allowed**, not denied. Flip that on the phone's
|
||||||
settings panel if you want the opposite.
|
settings panel if you want the opposite.
|
||||||
- Every failure path is fail-open: daemon down, timeout, bad JSON — the tool call proceeds. This
|
- Every failure path is fail-open: daemon down, timeout, bad JSON, a rejected hook secret — the tool
|
||||||
is a convenience gate, not a security boundary.
|
call proceeds. This is a convenience gate, not a security boundary.
|
||||||
|
|
||||||
`glance approval off` (the default) means Grok Build never blocks on the phone.
|
`glance approval off` (the default) means Grok Build never blocks on the phone.
|
||||||
|
|
||||||
@@ -73,8 +73,11 @@ and wait for a tap on the phone. Defaults that matter:
|
|||||||
- **"not running"** → `glance up`, then `glance logs`.
|
- **"not running"** → `glance up`, then `glance logs`.
|
||||||
- **Passkey prompt fails with a security error** → the phone is on a hostname the RP ID does not
|
- **Passkey prompt fails with a security error** → the phone is on a hostname the RP ID does not
|
||||||
cover. Compare `glance status`'s `rp id` with the hostname in the phone's address bar.
|
cover. Compare `glance status`'s `rp id` with the hostname in the phone's address bar.
|
||||||
- **Dashboard loads but shows nothing** → hooks are not firing. Check that the port in
|
- **Dashboard loads but shows nothing** → hooks are not firing. `glance status` warns if the hook
|
||||||
`hooks/hooks.json` matches `~/.grok/glance/config.json`; `glance sync-hooks` fixes it.
|
secret in `$GLANCE_HOME/hook.secret` no longer matches the one the daemon loaded (events are being
|
||||||
|
dropped with a 403); `glance stop && glance up` fixes that. Otherwise check that
|
||||||
|
`hooks/hooks.json` exists and is registered — it is generated, so `glance sync-hooks` rebuilds it
|
||||||
|
from the template.
|
||||||
- **Page says "run npm install && npm run build"** → the web bundle is missing; build it.
|
- **Page says "run npm install && npm run build"** → the web bundle is missing; build it.
|
||||||
|
|
||||||
## What it deliberately does not do
|
## What it deliberately does not do
|
||||||
|
|||||||
Reference in New Issue
Block a user