I have no grok CLI on this machine, so `grok plugin marketplace add` is taken from the docs rather than run. Say so, point at the TUI's Marketplace tab as the route that does not depend on a subcommand name, and note that the plain clone into ~/.grok/plugins/ needs none of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
19 KiB
Markdown
376 lines
19 KiB
Markdown
# grok-glance
|
|
|
|
A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what
|
|
your agents are doing from your phone — and tap **approve** or **deny** when one wants to run
|
|
something risky. Several agents at once is the normal case, not an edge case.
|
|
|
|
It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files,
|
|
or drive a session.
|
|
|
|
```
|
|
┌────────────────────────────┐
|
|
│ ● grok-glance 2 working ·│
|
|
│ 1 waiting │
|
|
├────────────────────────────┤
|
|
│ Waiting on you 62s │
|
|
│ Bash · in ●2 remote-grok │
|
|
│ rm -rf ./dist │
|
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │
|
|
│ [ Deny ] [ Approve ] │
|
|
├────────────────────────────┤
|
|
│ Agents 3 live │
|
|
│ All agents 3 │
|
|
│ ●1 remote-grok working │
|
|
│ Read 4s state.ts +2 │
|
|
│ 31 tools now │
|
|
│ ●2 remote-grok waiting │
|
|
│ Bash 1m rm -rf ./dist │
|
|
│ 12 tools 1 failed 4s │
|
|
│ ●3 docs-site error │
|
|
│ build failed 2m │
|
|
├────────────────────────────┤
|
|
│ Activity │
|
|
│ ● Read state.ts ●1 14:22 │
|
|
│ ● Bash npm test ●3 14:21 │
|
|
└────────────────────────────┘
|
|
```
|
|
|
|
## Requirements
|
|
|
|
- Node.js 20 or newer. **npm is only needed to develop it** — `dist/` is committed, and the
|
|
daemon bundle carries its one runtime dependency inside it, so an installed copy never runs
|
|
a build or an install step.
|
|
- Grok Build.
|
|
- For phone access: [Tailscale](https://tailscale.com/) on both the machine and the phone. See
|
|
[Why Tailscale](#why-tailscale-and-not-just-the-lan-ip) — a LAN IP genuinely cannot work.
|
|
|
|
## Install
|
|
|
|
Grok Build loads plugins straight out of `~/.grok/plugins/`, so the shortest install is a clone:
|
|
|
|
```sh
|
|
git clone <this repo> ~/.grok/plugins/grok-glance
|
|
```
|
|
|
|
That is the whole thing — no `npm install`, no build. Open `/plugins` in Grok Build and enable
|
|
**grok-glance**. On the next session start its `SessionStart` hook boots the daemon in the
|
|
background, and the dashboard is on `http://127.0.0.1:8791`.
|
|
|
|
### …or from a marketplace, by URL
|
|
|
|
If you would rather install it the way marketplace plugins are installed — or point several
|
|
machines at one URL — this repo is also its own one-entry marketplace:
|
|
`.grok-plugin/marketplace.json` lists exactly one plugin, sourced from `./`, which is the repo
|
|
root the catalog itself lives in. So the repo's git URL is a complete marketplace, with no commit
|
|
SHA to pin (a self-referencing remote source would have to pin the SHA of the commit that contains
|
|
the pin).
|
|
|
|
Add it as a marketplace source from the TUI — `/plugins`, Marketplace tab — or from the CLI:
|
|
|
|
```sh
|
|
grok plugin marketplace add https://your-git-host/you/grok-glance.git
|
|
grok plugin install grok-glance --trust
|
|
```
|
|
|
|
Configured sources are recorded in `~/.grok/config.toml` under `[[marketplace.sources]]` and in
|
|
`~/.grok/plugins/known_marketplaces.json`; the TUI and `grok plugin marketplace list` read the same
|
|
list. Check `grok plugin marketplace --help` if the subcommand names have moved — the clone above
|
|
does not depend on any of this.
|
|
|
|
### Why there is no build step
|
|
|
|
`dist/` is checked in:
|
|
|
|
- `dist/server/index.js` — the daemon, bundled to a single dependency-free ESM file. Its only
|
|
runtime dependency, `@simplewebauthn/server`, is inlined; everything else it uses is the Node
|
|
standard library. It is *not* minified, so what ships is what you can read.
|
|
- `dist/web/` — the dashboard, already a static bundle, which the daemon serves itself.
|
|
|
|
The hook scripts under `bin/` were stdlib-only from the start. So a clone has nothing to resolve
|
|
and nothing to compile, which is what makes a bare git URL enough.
|
|
|
|
Working on it instead? Then you do need the toolchain:
|
|
|
|
```sh
|
|
npm install
|
|
npm run build # tsc typechecks, rolldown bundles the server, vite builds the web app
|
|
npm run check:dist # rebuilds and fails if the committed dist/ is stale
|
|
```
|
|
|
|
`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`.
|
|
|
|
## 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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
node bin/glance set-origin https://<box>.<tailnet>.ts.net
|
|
```
|
|
|
|
## Enrol the phone
|
|
|
|
```sh
|
|
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
|
|
|
|
- **Agents** — one row per agent whenever there is more than one: badge, workspace, state, what it
|
|
is running right now, and how long ago it last did anything. Tap one to focus it; tap **All
|
|
agents** to come back. Ended sessions are folded away behind a toggle.
|
|
- **Now** — the focused agent: its workspace, state (working / waiting on you / idle / error /
|
|
ended), the last thing you asked, **every** tool it currently has in flight with a live elapsed
|
|
timer each, and running counts of tools, failures and denials.
|
|
- **Activity** — a merged timeline of prompts, tool calls with durations, failures, permission
|
|
denials, notifications, subagents, compactions, session start/end. Each row is stamped with the
|
|
badge of the agent it came from; focusing an agent filters it down to that one.
|
|
- **Pending approvals** — a card per waiting tool call, with the command, which agent is asking, 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.
|
|
|
|
## Several agents at once
|
|
|
|
Watching four agents on a phone is a different problem from watching one, so a few things are not
|
|
what you might assume:
|
|
|
|
- **Badges, not names.** Labels are workspace basenames, so two agents in the same repo are both
|
|
"remote-grok". The daemon hands each session a small ordinal in arrival order — `●1`, `●2` — and
|
|
the dashboard colours everything belonging to that agent with it: its row, its timeline lines, its
|
|
approval cards. The ordinal survives a daemon restart.
|
|
- **Sorted by who needs you, then fixed.** Rows are ordered *waiting → error → working → idle →
|
|
ended*, and ties break on badge. Within a state an agent never changes position, because a list
|
|
that re-sorts on every event moves the row out from under a thumb already heading for it.
|
|
- **A chatty agent cannot bury the others.** The event ring is global, but eviction always takes
|
|
from whichever session currently holds the most of it. One agent in a tight loop trims itself
|
|
rather than blanking everyone else's history.
|
|
- **Parallel tool calls are all shown.** An agent runs several tools at once; the focused card lists
|
|
them and the overview row shows the first with a `+2`. Durations are matched oldest-first per tool
|
|
name, since hook payloads carry no call id.
|
|
- **A restart does not lose the roster.** Events carry no workspace root, so the session map is
|
|
persisted separately (`sessions.json`) and reloaded on boot — otherwise every agent would come
|
|
back nameless until it happened to speak again. In-flight tools are deliberately *not* restored:
|
|
they belonged to a process that no longer exists.
|
|
|
|
`glance status` shows the same breakdown from a terminal:
|
|
|
|
```
|
|
sessions : 4 (1 waiting on you, 1 error, 2 working)
|
|
```
|
|
|
|
## Remote approve / deny
|
|
|
|
Off by default. Turn it on from the phone's settings panel, or:
|
|
|
|
```sh
|
|
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` | 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
|
|
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 |
|
|
|
|
## 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 |
|
|
| `sessions.json` | The agent roster — label, badge, workspace, state, counts — so a restart comes back with the overview intact. Written debounced, flushed on shutdown; sessions older than 12 hours are dropped on load. |
|
|
| `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 |
|
|
|
|
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
|
|
|
|
**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 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**
|
|
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. 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
|
|
`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 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.
|
|
|
|
## 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.
|