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 daemon serves the dashboard itself.
|
||||
the daemon serves the dashboard itself. It also generates `hooks/hooks.json` from
|
||||
`hooks/hooks.template.json`, and creates `~/.grok/glance/hook.secret` (mode 0600) if it does not
|
||||
exist yet — the shared secret the hook scripts authenticate with. Neither the secret nor anything
|
||||
derived from it ends up in `hooks.json`.
|
||||
|
||||
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so
|
||||
for a local checkout the shortest path is a one-entry catalog. Create
|
||||
@@ -144,12 +147,13 @@ Defaults worth knowing:
|
||||
|---|---|---|---|
|
||||
| Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. |
|
||||
| On timeout | allow | yes | Flip to *deny* if you would rather fail closed. |
|
||||
| Timeout | 90s | no — edit `config.json` | The hook's own timeout is 125s; raising this past that would just make the hook give up first. |
|
||||
| Timeout | 90s | no — edit `config.json` | The hook's own timeout is derived from this (`+35s` of slack) when `hooks.json` is generated, so re-run `sync-hooks` after changing it. |
|
||||
| Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. |
|
||||
|
||||
**This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon
|
||||
down, hook timeout, malformed response, port mismatch — the tool call proceeds. If you need calls
|
||||
actually blocked, use Grok Build's own permission settings.
|
||||
down, hook timeout, malformed response, port mismatch, a hook secret the daemon no longer
|
||||
recognises — the tool call proceeds. If you need calls actually blocked, use Grok Build's own
|
||||
permission settings.
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -167,7 +171,7 @@ actually blocked, use Grok Build's own permission settings.
|
||||
| `devices` | List enrolled devices |
|
||||
| `revoke <id-prefix>` | Revoke a device |
|
||||
| `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
|
||||
|
||||
@@ -180,6 +184,7 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i
|
||||
| `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens |
|
||||
| `secret.key` | 32-byte HMAC key used to sign session cookies |
|
||||
| `admin.token` | Rotated every daemon start; authenticates the CLI |
|
||||
| `hook.secret` | Shared secret the hook scripts present on `/hook/*`. Created once, mode 0600, never rotated — a rotation mid-session would 403 whatever was already in flight. Delete it and the daemon mints a new one on its next start; hooks then need that restart to agree again, which `glance status` will tell you about. |
|
||||
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
|
||||
| `daemon.log` | Daemon stdout/stderr |
|
||||
|
||||
@@ -192,8 +197,10 @@ instance without touching your real one:
|
||||
| `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) |
|
||||
| `GLANCE_ORIGIN` | Public origin, as if set with `set-origin` — but not persisted |
|
||||
|
||||
To change the port, edit `config.json`, then run `node bin/glance sync-hooks` so the hook URLs in
|
||||
`hooks/hooks.json` match. Restart the daemon afterwards.
|
||||
The port is not baked into `hooks/hooks.json` — the hook scripts read `config.json` themselves — so
|
||||
changing it needs nothing but a daemon restart. Changing `approval.timeoutMs` does affect the
|
||||
generated file: run `node bin/glance sync-hooks` afterwards so the approval hook's own timeout still
|
||||
outlasts the wait.
|
||||
|
||||
## Security notes
|
||||
|
||||
@@ -202,13 +209,20 @@ its own. Three classes of caller:
|
||||
|
||||
| Path | Caller | Authentication |
|
||||
|---|---|---|
|
||||
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | none — loopback only |
|
||||
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | shared secret from `hook.secret`, plus a refusal of any proxied request |
|
||||
| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header |
|
||||
| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` |
|
||||
|
||||
`/local/*` is token-gated rather than "is it from localhost", because `tailscale serve` proxies
|
||||
remote traffic to `127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by
|
||||
address alone.
|
||||
None of the three trusts the source address, because `tailscale serve` proxies remote traffic to
|
||||
`127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by address alone. Without
|
||||
the hook secret, anyone who could reach the tunnel could forge timeline events and answer approval
|
||||
prompts on your behalf; `/hook/*` compares the secret in constant time before it reads a body, and
|
||||
additionally refuses any request carrying `x-forwarded-for` or `x-forwarded-proto`, which a local
|
||||
hook process never sends and a tunnelled caller always does.
|
||||
|
||||
That refusal costs nothing, because no legitimate hook traffic comes through the tunnel: hooks are
|
||||
local processes talking to loopback. It applies **only** to `/hook/*` — the dashboard arrives
|
||||
through `tailscale serve` with those headers on every request and is unaffected.
|
||||
|
||||
**Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request
|
||||
arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions
|
||||
@@ -256,21 +270,36 @@ stop working and must be enrolled again.
|
||||
|
||||
## Hook wiring
|
||||
|
||||
`hooks/hooks.json` subscribes to all 14 lifecycle events. Passive events use `type: "http"`: they
|
||||
POST straight into the daemon with no process spawn, so they cost close to nothing per tool call
|
||||
and quietly do nothing when the daemon is down.
|
||||
`hooks/hooks.json` subscribes to all 14 lifecycle events, and **is generated** — from
|
||||
`hooks/hooks.template.json` by `scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on
|
||||
`node bin/glance sync-hooks`. Edit the template, not the output.
|
||||
|
||||
Two exceptions:
|
||||
Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon
|
||||
by any route. Grok Build's http runner rejects every scheme but `https`, then **resolves the host**
|
||||
and refuses the resolved address if it is private, link-local or CGNAT
|
||||
(`xai-grok-hooks/src/runner/http.rs`, `validate_hook_url` + `is_blocked_ip`). Plain http on loopback
|
||||
fails the scheme check; the tailnet fails the address check, because `*.ts.net` resolves into
|
||||
`100.64/10` (and `fd7a::/48`, inside the blocked `fc00::/7`). On top of that the runner sends no
|
||||
request header but `Content-Type`, with no configuration surface for one, so such a hook could not
|
||||
authenticate itself even if it could connect. The generator refuses to emit an `http` handler whose
|
||||
URL is not `https://`, because the alternative is what this plugin shipped for a while: 13 passive
|
||||
hooks that failed validation silently on every event.
|
||||
|
||||
A command hook has none of those problems. It is a local process, so no URL is validated, nothing
|
||||
traverses the tunnel, and it can present the shared secret — hook traffic goes straight to
|
||||
`http://127.0.0.1:8791` and never leaves the machine. So each observed event runs
|
||||
`bin/glance-record.mjs`, which costs a Node start (~40 ms) and POSTs one event. Two entries differ:
|
||||
|
||||
- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon.
|
||||
- `PreToolUse` is wired **twice** — an `http` entry that records every call for the timeline, and a
|
||||
`command` entry matching only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs
|
||||
`bin/glance-approve.mjs`. PreToolUse is the only blocking event, and a command hook is the only
|
||||
documented way to return a deny decision, so the gate has to be a spawned process; keeping the
|
||||
match narrow means the cost is paid only for calls that could actually need a tap.
|
||||
- `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second entry matching
|
||||
only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs `bin/glance-approve.mjs`. PreToolUse
|
||||
is the only blocking event, and a command hook is the only documented way to return a deny
|
||||
decision; keeping the match narrow means the gate's cost is paid only for calls that could
|
||||
actually need a tap. Its `timeout` is derived from `approval.timeoutMs` at generation time rather
|
||||
than hand-copied, which is the other thing `sync-hooks` refreshes.
|
||||
|
||||
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
|
||||
deliberately denying.
|
||||
deliberately denying — including when the daemon rejects their token.
|
||||
|
||||
## Deliberately omitted
|
||||
|
||||
|
||||
Reference in New Issue
Block a user