first commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
# 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](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
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```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
|
||||
|
||||
- **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:
|
||||
|
||||
```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` | The hook's own timeout is 125s; raising this past that would just make the hook give up first. |
|
||||
| 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.
|
||||
|
||||
## 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` | Rewrite hook URLs after changing the port |
|
||||
|
||||
## 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 |
|
||||
| `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 |
|
||||
|
||||
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.
|
||||
|
||||
## 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 | none — loopback only |
|
||||
| `/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.
|
||||
|
||||
**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. 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.
|
||||
|
||||
Two exceptions:
|
||||
|
||||
- `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.
|
||||
|
||||
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
|
||||
deliberately denying.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user