iceBear67andClaude Opus 5 5eec1940be 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>
2026-08-09 04:52:17 +00:00
2026-08-09 04:00:13 +00:00
2026-08-09 04:00:13 +00:00
2026-08-09 04:00:13 +00:00
2026-08-09 04:00:13 +00:00
2026-08-09 04:00:13 +00:00
2026-08-09 04:00:13 +00:00
2026-08-09 04:00:13 +00:00
2026-08-09 04:00:13 +00:00

grok-glance

A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what an agent is doing from your phone — and tap approve or deny when it wants to run something risky.

It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files, or drive a session.

┌────────────────────────────┐
│ ● grok-glance    2 sessions│
├────────────────────────────┤
│  Waiting on you      62s   │
│  Bash · in remote-grok     │
│  rm -rf ./dist             │
│  ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░   │
│  [   Deny   ] [  Approve ] │
├────────────────────────────┤
│  remote-grok      working  │
│  ~/src/remote-grok         │
│  Last asked: fix the flaky │
│  ⟳ Read  4s                │
│    server/src/state.ts     │
│   12 tools  0 failed  0 ✗  │
├────────────────────────────┤
│  Activity                  │
│  ● Read state.ts    14:22  │
│  ● Bash npm test    14:21  │
└────────────────────────────┘

Requirements

  • Node.js 20 or newer, and npm.
  • Grok Build.
  • For phone access: Tailscale on both the machine and the phone. See Why Tailscale — a LAN IP genuinely cannot work.

Install

git clone <this repo> grok-glance
cd grok-glance
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.

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 .grok-plugin/marketplace.json in a directory that contains your checkout:

{
  "name": "local",
  "description": "Local plugins",
  "owner": { "name": "me" },
  "plugins": [
    {
      "name": "grok-glance",
      "description": "Passkey-guarded phone dashboard for Grok Build.",
      "category": "monitoring",
      "source": { "type": "local", "path": "./grok-glance" }
    }
  ]
}

…then add that marketplace and install grok-glance from Grok Build's /plugin interface.

Once installed, the daemon starts by itself: the SessionStart hook boots it in the background on the first session after installation.

Get it onto your phone

The daemon binds to 127.0.0.1 only and never opens a port to your network. Tailscale Serve publishes it inside your tailnet with real TLS:

tailscale serve --bg 127.0.0.1:8791
tailscale serve status     # note the https://<box>.<tailnet>.ts.net URL

Tell grok-glance which origin it is being served on — this is also the WebAuthn relying-party ID, so it has to be exact:

node bin/glance set-origin https://<box>.<tailnet>.ts.net

Enrol the phone

node bin/glance enroll

That prints a URL and an 8-character code, good for 10 minutes, single use:

  Open this on your phone:

    https://mybox.tailnet-1234.ts.net/?enroll

  Enrollment code:  K7QM4RTX
  Valid for:        10 minutes (single use)

Open the URL on the phone, type the code, tap Create passkey, and confirm with Face ID / a fingerprint / the device PIN. From then on the phone unlocks the dashboard with that passkey and nothing else gets in.

Repeat for each device you want. node bin/glance devices lists them; node bin/glance revoke <id-prefix> removes one (and kills its live session immediately).

What the dashboard shows

  • Now — the workspace, its state (working / waiting on you / idle / error / ended), the last thing you asked, the tool currently running with a live elapsed timer, and running counts of tools, failures and denials.
  • Sessions — one row per live session when there is more than one; tap to filter.
  • Activity — a timeline of prompts, tool calls with durations, failures, permission denials, notifications, subagents, compactions, session start/end.
  • Pending approvals — a card per waiting tool call, with the command, a countdown, and two large buttons.

Updates arrive over Server-Sent Events. The server sends whole snapshots rather than deltas, so a phone that slept through twenty events still wakes up showing the truth.

Remote approve / deny

Off by default. Turn it on from the phone's settings panel, or:

node bin/glance approval risky     # Bash, Write, Edit, MultiEdit, NotebookEdit
node bin/glance approval all       # every tool call — noisy
node bin/glance approval off

Defaults worth knowing:

Behaviour Default Changeable from the phone Why
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.
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, 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

bin/glance is plain Node with no dependencies. Run it as node bin/glance <command>.

Command What it does
status Running? On which origin, with how many devices?
up Start the daemon in the background
serve Run it in the foreground (for debugging)
stop Stop it
logs Last 60 lines of the daemon log
enroll Mint a one-time enrolment code and URL
set-origin <url> Set the public https origin and RP ID
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

Everything lives in ~/.grok/glance (mode 0700), or $GLANCE_HOME if you set it:

File Contents
config.json Port, bind host, public origin, RP ID, approval settings
credentials.json Enrolled passkeys — credential IDs, public keys, counters. No secrets of yours.
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

Three environment variables override config.json, which is mostly useful for testing a second instance without touching your real one:

Variable Effect
GLANCE_HOME Where all of the above lives. Read by the daemon, the CLI, and the hook scripts.
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.

Security notes

Network surface. The daemon listens on 127.0.0.1:8791 and makes no outbound connections of its own. Three classes of caller:

Path Caller Authentication
/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

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 last 30 days; revoking a device drops its session at once.

Cross-site defence. Every POST must carry exactly content-type: application/json, which is not a CORS-safelisted type, so a hostile page cannot post here without a preflight that is never answered. /api/* additionally requires an x-glance-csrf header. The pages themselves are served with a strict CSP (script-src 'self', no framing, no form actions) and no-store.

User verification is required, for both enrolment and sign-in: the phone asks for a biometric or PIN every time, so a stolen unlocked phone is not automatically a way in. Passkeys are created as resident keys, so the phone offers the right one without you typing a username.

Rate limits. Authentication attempts are capped (40 per 5 minutes), enrolment attempts more tightly (12 per 5 minutes, and a code burns itself after 5 wrong guesses). The limiter is keyed globally on purpose: behind a tunnel every request arrives from 127.0.0.1, so per-IP buckets would be one bucket wearing a hat.

What crosses the wire. Tool names, truncated arguments, prompt first lines, file paths, durations, and exit statuses — a summary, not a transcript. Known secret shapes are redacted before storage: sk-/rk-/pk- keys, xai- keys, GitHub and Slack tokens, AWS access key IDs, JWTs, PEM private-key blocks, Authorization:/Bearer headers, and password/secret/token/api_key assignments. This is a filter, not a guarantee — a secret in an unusual shape will show up in the timeline. Treat the dashboard as being as sensitive as your terminal.

Threat model. grok-glance assumes the machine it runs on is trusted. It protects against someone else on your tailnet, or a browser tab you left open, reaching the dashboard. It does not protect against a local attacker who can read ~/.grok/glance — with admin.token they can enrol their own device.

Why Tailscale, and not just the LAN IP?

WebAuthn will not run outside a secure context, and — separately — a bare IP address cannot be a relying-party ID. So http://192.168.1.20:8791 can never hold a passkey, no matter what the browser is willing to render. You need a hostname with valid TLS. Tailscale Serve gives you one for free, with the tunnel closed to everything outside your tailnet.

Any other route to a real https hostname works too: set origin in config.json (or use set-origin) to whatever your reverse proxy terminates on. If you put grok-glance behind a proxy reachable from the public internet, the passkey gate is the only thing standing in front of it.

Changing the origin changes the RP ID, and passkeys are bound to the RP ID — existing devices 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.

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 — 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 — including when the daemon rejects their token.

Deliberately omitted

Not oversights — decisions:

  • Sending prompts or steering the session. Hooks cannot inject input, so a phone-to-agent channel would need a second transport and a much larger threat model.
  • Full transcripts and tool output. Summaries only. Streaming assistant text through a phone would mean shipping your codebase through it.
  • Push notifications. Needs a VAPID key, a service worker, and a subscription store, for a dashboard you open on purpose.
  • Multi-user accounts and roles. One user, several devices.
  • Diff views, file browsing, cost/token charts, log search, session resume. All out of scope for a glance.
  • Editing the risky-tool pattern from the phone. Shown but not editable: a typo'd regex there would silently change what gets gated. Edit config.json instead.

Licence

MIT.

S
Description
No description provided
Readme
720 KiB
Languages
TypeScript 85%
JavaScript 13.7%
CSS 0.7%
HTML 0.6%