docs(pair-agent): document the full-access default, --restrict, and real revocation

The pairing docs still described the pre-b73f3644 model: read+write default,
--admin as the opt-in for JS/cookies/storage. Reality for three releases:
/pair grants read+write+admin+meta (the pairing ceremony is the trust
boundary) and --admin is a legacy alias for --control. A user following the
skill believed they granted a sandboxed session and actually granted JS
execution on their logged-in browser.

pair-agent/SKILL.md.tmpl (SKILL.md regenerated in this commit) now states
the real default, the tunnel-allowlist nuance (eval works remotely; the
js/cookies/storage commands are local-only), --restrict for sandboxed
sessions with an untrusted-content advisory (scope caps prompt-injection
blast radius), and --control for browser-wide ops. "Revoking access"
documents the now-real tunnel revoke (deletes session + pending setup keys,
verifies against the agent list) and tunnel agents, and replaces the
never-implemented `tunnel rotate` with `$B stop` — tokens are memory-only,
so a daemon restart already rotates everything.

REMOTE_BROWSER_ACCESS.md: /connect example shows the real default scopes,
the scope table gains the control row, the 403 hint row matches the new
server wording, and the false claim that /sidebar-chat is on the tunnel
allowlist is gone (TUNNEL_PATHS is /connect + /command; /sidebar-chat no
longer exists in server.ts at all). ARCHITECTURE.md drops the same phantom
endpoint from the allowlist prose and endpoint table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-20 04:03:20 +00:00
co-authored by Claude Fable 5
parent 2dfd6edeee
commit 39d11714cc
4 changed files with 100 additions and 32 deletions
+1 -2
View File
@@ -92,7 +92,7 @@ When a user runs `pair-agent --client`, the daemon starts an ngrok tunnel so a r
The fix is **two HTTP listeners**, not one:
- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves token bootstrap (`POST /extension-token`, released only to the pinned extension identity), `/health` (liveness/status only — never a token), `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited), `/command` (scoped tokens only, further restricted to a browser-driving command allowlist), and `/sidebar-chat`. Everything else 404s.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited) and `/command` (scoped tokens only, further restricted to a browser-driving command allowlist). Everything else 404s.
ngrok forwards only the tunnel port. The security property comes from **physical port separation**: a tunnel caller cannot reach `/health` or `/cookie-picker` because those paths don't exist on that TCP socket. Header inference (check `x-forwarded-for`, check origin) is unreliable (ngrok header behavior changes; local proxies can add these headers); socket separation isn't.
@@ -103,7 +103,6 @@ ngrok forwards only the tunnel port. The security property comes from **physical
| `GET /connect` | public (`{alive:true}`) | public (`{alive:true}`) | Probe path for tunnel liveness |
| `POST /connect` | public (rate-limited 300/min) | public (rate-limited) | Setup-key exchange for pair-agent |
| `POST /command` | auth (Bearer root OR scoped) | auth (scoped only, allowlisted commands) | Root token on tunnel = 403 |
| `POST /sidebar-chat` | auth | auth | Lets remote agent post into local sidebar |
| `POST /pair` | root-only | 404 | Pairing mint — local operator action |
| `POST /tunnel/{start,stop}` | root-only | 404 | Daemon configuration |
| `POST /token`, `DELETE /token/:id` | root-only | 404 | Scoped token mint/revoke |
+9 -8
View File
@@ -17,8 +17,8 @@ GStack Browser Server Any AI agent
├── Local listener 127.0.0.1:LOCAL │
│ (bootstrap, CLI, sidebar, cookies) │
├── Tunnel listener 127.0.0.1:TUNNEL ◄───────┤
│ (pair-agent only: /connect, /command,
│ /sidebar-chat — locked allowlist) │
│ (pair-agent only: /connect and
│ /command — locked allowlist)
├── ngrok tunnel (forwards tunnel port only) │
│ https://xxx.ngrok.dev ─────────────────┘
└── Token Registry
@@ -32,7 +32,7 @@ GStack Browser Server Any AI agent
The daemon binds two HTTP sockets. The **local listener** serves the full command surface to 127.0.0.1 only and is never forwarded. The **tunnel listener** is bound lazily on `/tunnel/start` (and torn down on `/tunnel/stop`) with a locked path allowlist. ngrok forwards only the tunnel port.
A caller who stumbles onto your ngrok URL cannot reach `/health`, `/cookie-picker`, `/inspector/*`, or `/welcome` — those paths don't exist on that TCP socket. Root tokens sent over the tunnel get 403. The tunnel listener accepts only `/connect`, `/command` (with a scoped token + the 26-command browser-driving allowlist), and `/sidebar-chat`.
A caller who stumbles onto your ngrok URL cannot reach `/health`, `/cookie-picker`, `/inspector/*`, or `/welcome` — those paths don't exist on that TCP socket. Root tokens sent over the tunnel get 403. The tunnel listener accepts only `/connect` and `/command` (with a scoped token + the 26-command browser-driving allowlist).
See [ARCHITECTURE.md](../ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) for the full endpoint table.
@@ -67,7 +67,7 @@ Exchange a setup key for a session token. No auth required. Rate-limited to 300/
```json
Request: {"setup_key": "gsk_setup_..."}
Response: {"token": "gsk_sess_...", "expires": "ISO8601", "scopes": ["read","write"], "agent": "agent-name"}
Response: {"token": "gsk_sess_...", "expires": "ISO8601", "scopes": ["read","write","admin","meta"], "agent": "agent-name"}
```
#### POST /command
@@ -146,8 +146,9 @@ CSS selectors. Always `snapshot -i` first, then use the refs.
| `write` | goto, click, fill, scroll, newtab, closetab, etc. |
| `admin` | eval, js, cookies, storage, cookie-import, useragent, etc. |
| `meta` | tab, diff, frame, responsive, watch |
| `control` | stop, restart, disconnect, state, handoff — browser-wide destructive ops |
Default tokens get `read` + `write`. Admin requires `--admin` flag when pairing.
Paired agents get `read+write+admin+meta` by default; the pairing ceremony is the trust boundary. `--restrict` narrows the list (it can never grant `control`). `--control` adds the control scope (`--admin` is a legacy alias). Over the tunnel, the `js`/`cookies`/`storage` commands are blocked by the command allowlist regardless of scope; `eval` works. Pair with `--restrict "read,write"` when the agent will read untrusted web content — scope caps the prompt-injection blast radius.
## Tab Isolation
@@ -162,7 +163,7 @@ Each agent owns the tabs it creates. Rules:
| Code | Meaning | What to do |
|------|---------|------------|
| 401 | Token invalid, expired, or revoked | Ask user to run /pair-agent again |
| 403 | Command not in scope, or tab not yours | Use newtab, or ask for --admin |
| 403 | Command not in scope, tab not yours, or not on the tunnel allowlist | Use newtab; the user can re-pair without --restrict or with --control |
| 429 | Rate limit exceeded (>10 req/s) | Wait for Retry-After header |
## Security Model
@@ -173,8 +174,8 @@ Each agent owns the tabs it creates. Rules:
- **Setup keys** expire in 5 minutes and can only be used once.
- **Session tokens** expire in 24 hours (configurable).
- The root token never appears in instruction blocks or connection strings.
- **Admin scope** (JS execution, cookie access) is denied by default.
- Tokens can be revoked instantly: `$B tunnel revoke agent-name`
- **Control scope** (stop/restart/disconnect) is denied by default and never rides in via a scopes list. Admin is granted at pairing; `js`/`cookies`/`storage` stay blocked over the tunnel by the command allowlist. Use `--restrict` for less-trusted agents.
- Tokens can be revoked instantly: `$B tunnel revoke agent-name` deletes the session plus any pending setup keys and verifies against the live agent list. `$B tunnel agents` shows who's paired (pending setup keys included). `$B stop` clears everything — tokens never survive the daemon.
- **SSE auth** uses a 30-minute HttpOnly SameSite=Strict cookie, stream-scope only (never valid against `/command`).
- **Path traversal guarded** on `/welcome` — `GSTACK_SLUG` must match `^[a-z0-9_-]+$` or falls back to the built-in template.
- **SSRF guards** on `goto`, `download`, and scrape paths — validates URL target against a localhost/private-range blocklist.
+45 -11
View File
@@ -22,7 +22,8 @@ allowed-tools:
One command generates a setup key and
prints instructions the other agent can follow to connect. Works with OpenClaw,
Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent
gets its own tab with scoped access (read+write by default, admin on request).
gets its own tab with full page access by default (the pairing ceremony is the
trust boundary; --restrict narrows it).
Use when asked to "pair agent", "connect agent", "share browser", "remote browser",
"let another agent use my browser", or "give browser access".
@@ -1000,10 +1001,18 @@ ngrok, start the tunnel, and print the instruction block with the tunnel URL:
$B pair-agent --client TARGET_HOST
```
If the user also needs admin access (JS execution, cookies, storage):
Default access already includes JS execution. To also grant browser-wide
control (stop, restart, disconnect):
```bash
$B pair-agent --admin --client TARGET_HOST
$B pair-agent --control --client TARGET_HOST
```
For a less-trusted agent, narrow the scopes instead:
```bash
$B pair-agent --restrict read --client TARGET_HOST # read-only
$B pair-agent --restrict "read,write" --client TARGET_HOST # no JS, no cookies
```
**CRITICAL: You MUST output the full instruction block to the user.** The command
@@ -1075,15 +1084,28 @@ side panel if you have GStack Browser open."
## What the remote agent can do
With default (read+write) access:
Default access is read+write+admin+meta. The trust boundary is the pairing
ceremony, not the scope:
- Navigate to URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Cannot execute arbitrary JavaScript, read cookies, or access storage
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
With admin access (--admin flag):
- Everything above, plus JS execution, cookie access, storage access
- Use sparingly. Only for agents you fully trust.
Remote agents go through the tunnel command allowlist: `eval` works, but the
`js`, `cookies`, and `storage` commands are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all four.
With --restrict (`--restrict read`, `--restrict "read,write"`):
- Sandboxed sessions: read-only, or read+write with no JS, cookie, or storage
access. Pair this way when the remote agent will read untrusted web content:
a trusted agent can be prompt-injected by pages it reads, and scope caps the
blast radius (eval works over the tunnel).
- `--restrict` never grants `control`; that scope stays behind --control.
With --control (--admin is the legacy alias):
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
- Only for agents you fully trust.
## Troubleshooting
@@ -1131,9 +1153,21 @@ To disconnect a specific agent:
$B tunnel revoke AGENT_NAME
```
To disconnect all agents and rotate the root token:
The command deletes every token for that agent (the session and any pending
setup keys) and re-reads the agent list to prove it's gone.
See who's paired:
```bash
# This invalidates ALL scoped tokens immediately
$B tunnel rotate
$B tunnel agents
```
Unexchanged setup keys show as "(pending)"; `tunnel revoke` removes them too.
To disconnect ALL agents at once, stop the daemon. Scoped tokens live in
daemon memory and never survive a restart; the next command boots a fresh
daemon with a new root token:
```bash
$B stop
```
+45 -11
View File
@@ -6,7 +6,8 @@ description: |
Pair a remote AI agent with your browser. One command generates a setup key and
prints instructions the other agent can follow to connect. Works with OpenClaw,
Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent
gets its own tab with scoped access (read+write by default, admin on request).
gets its own tab with full page access by default (the pairing ceremony is the
trust boundary; --restrict narrows it).
Use when asked to "pair agent", "connect agent", "share browser", "remote browser",
"let another agent use my browser", or "give browser access". (gstack)
voice-triggers:
@@ -185,10 +186,18 @@ ngrok, start the tunnel, and print the instruction block with the tunnel URL:
$B pair-agent --client TARGET_HOST
```
If the user also needs admin access (JS execution, cookies, storage):
Default access already includes JS execution. To also grant browser-wide
control (stop, restart, disconnect):
```bash
$B pair-agent --admin --client TARGET_HOST
$B pair-agent --control --client TARGET_HOST
```
For a less-trusted agent, narrow the scopes instead:
```bash
$B pair-agent --restrict read --client TARGET_HOST # read-only
$B pair-agent --restrict "read,write" --client TARGET_HOST # no JS, no cookies
```
**CRITICAL: You MUST output the full instruction block to the user.** The command
@@ -260,15 +269,28 @@ side panel if you have GStack Browser open."
## What the remote agent can do
With default (read+write) access:
Default access is read+write+admin+meta. The trust boundary is the pairing
ceremony, not the scope:
- Navigate to URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Cannot execute arbitrary JavaScript, read cookies, or access storage
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
With admin access (--admin flag):
- Everything above, plus JS execution, cookie access, storage access
- Use sparingly. Only for agents you fully trust.
Remote agents go through the tunnel command allowlist: `eval` works, but the
`js`, `cookies`, and `storage` commands are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all four.
With --restrict (`--restrict read`, `--restrict "read,write"`):
- Sandboxed sessions: read-only, or read+write with no JS, cookie, or storage
access. Pair this way when the remote agent will read untrusted web content:
a trusted agent can be prompt-injected by pages it reads, and scope caps the
blast radius (eval works over the tunnel).
- `--restrict` never grants `control`; that scope stays behind --control.
With --control (--admin is the legacy alias):
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
- Only for agents you fully trust.
## Troubleshooting
@@ -316,9 +338,21 @@ To disconnect a specific agent:
$B tunnel revoke AGENT_NAME
```
To disconnect all agents and rotate the root token:
The command deletes every token for that agent (the session and any pending
setup keys) and re-reads the agent list to prove it's gone.
See who's paired:
```bash
# This invalidates ALL scoped tokens immediately
$B tunnel rotate
$B tunnel agents
```
Unexchanged setup keys show as "(pending)"; `tunnel revoke` removes them too.
To disconnect ALL agents at once, stop the daemon. Scoped tokens live in
daemon memory and never survive a restart; the next command boots a fresh
daemon with a new root token:
```bash
$B stop
```