browse: keep tunnel-paired agents on web URLs and away from the daemon's disk (#4116)

* browse: keep tunnel-paired agents on web URLs and off the daemon's disk

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Tunnel instruction block screenshot example uses --base64; newtab gates the URL its handler opens (skipping --json); Security Model bullet says 25 commands.

Review-Finding: p1/specialist-batched#1 reader=specialist-batched
Review-Finding: p1/specialist-batched#2 reader=specialist-batched
Review-Finding: p1/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (round 2)

Tunnel gate refuses null args and non-string elements with the logged 403; the screenshot 403 hint names --selector <css>; docs and changelog say tunnel agents cannot read local files or choose where anything is written, and that fixed-name captures still go to the temp dir.

Review-Finding: v1/adversarial-native#2 reader=adversarial-native
Review-Finding: v1/adversarial-native#3 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: tunnel screenshot needs --base64; snapshot -a/-H refused over the tunnel

Over the pair-agent tunnel nothing is written to the daemon's disk: screenshot must include --base64, snapshot refuses -a/--annotate and -H/--heatmap as well as -o, and the 403 hint names these limits. Docs and changelog state the claim again. Approved under B1 (a-d2bdd5d079e4).

Review-Finding: v1/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: tunnel gate applies the handler's args default; narrow the tunnel claim

The /command route passes [] to canDispatchOverTunnel when the body has no args field, matching the handler's default, so a tunnel screenshot without --base64 is refused in either form. Docs and changelog now say a paired agent can't read local files, can't choose a path on the daemon's disk, and can't make the daemon save screenshot or snapshot images. Approved under C1 (a-f778cd1e7945).

Review-Finding: p2/specialist-batched#1 reader=specialist-batched
Review-Finding: p2/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 07:14:20 +00:00
1 parent e75a69c9e1
commit de5cb247e0
12 files changed
+337 -101

No files matched your search

+15 -7
View File
@@ -350,7 +350,7 @@ Three escalating modes:
every command tick through in real time.
- **Pair-agent over a tunnel**. Daemon binds a second listener that ngrok
forwards. A remote agent (Codex, OpenClaw, Hermes, anything that can speak
HTTP) drives your local browser through a 26-command allowlist with a
HTTP) drives your local browser through a 25-command allowlist with a
scoped, single-use token.
---
@@ -1195,7 +1195,7 @@ resets once — a one-time in-product notice explains this.
Remote AI agents (Codex, OpenClaw, Hermes, anything that speaks HTTP) can
drive your local browser through an ngrok tunnel. The whole flow is gated
by a 26-command allowlist, scoped tokens, and a denial log.
by a 25-command allowlist, scoped tokens, and a denial log.
### How it works
@@ -1215,23 +1215,31 @@ When `pair-agent` activates, the daemon binds **two HTTP listeners**:
forwarded by ngrok. Used by your Claude Code, the Side Panel, anything
on your machine.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`). Locked allowlist —
`/connect`, `/command` (scoped tokens + 26-command browser-driving
`/connect`, `/command` (scoped tokens + 25-command browser-driving
allowlist), `/sidebar-chat`. ngrok forwards only this port.
Root tokens sent over the tunnel return 403. SSE endpoints use a 30-minute
HttpOnly `gstack_sse` cookie (never valid against `/command`).
### The 26-command tunnel allowlist
### The 25-command tunnel allowlist
Defined in `browse/src/server.ts` as `TUNNEL_COMMANDS`. Pure gate function
`canDispatchOverTunnel(command)` is exported for unit testing. Set:
Defined in `browse/src/routes/commands.ts` as `TUNNEL_COMMANDS`. Pure gate
function `canDispatchOverTunnel(command, args)` is exported for unit testing. Set:
```
goto, click, text, screenshot, html, links, forms, accessibility,
attrs, media, data, scroll, press, type, select, wait, eval,
attrs, media, data, scroll, press, type, select, wait,
newtab, tabs, back, forward, reload, snapshot, fill, url, closetab
```
The gate also reads the args, so over the tunnel a paired agent can't read
local files, can't choose a path on the daemon's disk, and can't make the
daemon save screenshot or snapshot images: `goto`/`newtab` take http(s) URLs only, `screenshot` needs
`--base64` (it returns the image) and cannot name an output path, `snapshot`
refuses `-a`/`--annotate`, `-H`/`--heatmap` and `-o`, and `--out` is
refused everywhere. `eval` is absent because it runs a file from the
daemon's disk.
Notably absent: `pair`, `unpair`, `cookies`, `setup`, `launch`, `restart`,
`stop`, `tunnel-start`, `token-mint`, `state`, `connect`, `disconnect`. A
remote agent that tries them gets a 403 plus a fresh entry in the denial log.
+1 -1
View File
@@ -1328,7 +1328,7 @@ COMMAND REFERENCE:
Navigate: {"command": "goto", "args": ["URL"], "tabId": N}
Snapshot: {"command": "snapshot", "args": ["-i"], "tabId": N}
Full text: {"command": "text", "args": [], "tabId": N}
Screenshot: {"command": "screenshot", "args": ["/tmp/s.png"], "tabId": N}
Screenshot: {"command": "screenshot", "args": ["--base64"], "tabId": N}
Click: {"command": "click", "args": ["@e3"], "tabId": N}
Fill form: {"command": "fill", "args": ["@e5", "value"], "tabId": N}
Go back: {"command": "back", "args": [], "tabId": N}
+72 -54
View File
@@ -22,6 +22,75 @@ import { resolveConfig } from './config';
import { filterSessionCookies } from './session-persist';
import type { Frame } from 'playwright';
/**
* Parse screenshot args. Priority: flags (--viewport, --clip, --base64,
* --selector) → selector (@ref, CSS) → output path. `outputPath` is set only
* when the caller named one; the tunnel gate refuses any that is.
*/
export function parseScreenshotArgs(args: string[]): {
outputPath?: string;
targetSelector?: string;
clipRect?: { x: number; y: number; width: number; height: number };
viewportOnly: boolean;
base64Mode: boolean;
} {
let outputPath: string | undefined;
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
let targetSelector: string | undefined;
let viewportOnly = false;
let base64Mode = false;
const remaining: string[] = [];
let flagSelector: string | undefined;
for (let i = 0; i < args.length; i++) {
const inline = /^(--clip|--selector)=(.*)$/s.exec(args[i]);
const flag = inline ? inline[1] : args[i];
const flagValue = () => (inline ? inline[2] : args[++i]);
if (flag === '--viewport') {
viewportOnly = true;
} else if (flag === '--base64') {
base64Mode = true;
} else if (flag === '--selector') {
flagSelector = flagValue();
if (!flagSelector) throw new Error('Usage: screenshot --selector <css> [path]');
} else if (flag === '--clip') {
const coords = flagValue();
if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
const parts = coords.split(',').map(Number);
if (parts.length !== 4 || parts.some(isNaN))
throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
} else if (args[i].startsWith('--')) {
throw new Error(`Unknown screenshot flag: ${args[i]}`);
} else {
remaining.push(args[i]);
}
}
// Separate target (selector/@ref) from output path
for (const arg of remaining) {
// File paths containing / and ending with an image/pdf extension are never CSS selectors
const isFilePath = arg.includes('/') && /\.(png|jpe?g|webp|pdf)$/i.test(arg);
if (isFilePath) {
outputPath = arg;
} else if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
targetSelector = arg;
} else {
outputPath = arg;
}
}
// --selector flag takes precedence; conflict with positional selector.
if (flagSelector !== undefined) {
if (targetSelector !== undefined) {
throw new Error('--selector conflicts with positional selector — choose one');
}
targetSelector = flagSelector;
}
return { outputPath, targetSelector, clipRect, viewportOnly, base64Mode };
}
/** Tokenize a pipe segment respecting double-quoted strings. */
function tokenizePipeSegment(segment: string): string[] {
const tokens: string[] = [];
@@ -440,61 +509,10 @@ export async function handleMetaCommand(
// ─── Visual ────────────────────────────────────────
case 'screenshot': {
// Parse priority: flags (--viewport, --clip, --base64) → selector (@ref, CSS) → output path
const page = bm.getPage();
let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
let targetSelector: string | undefined;
let viewportOnly = false;
let base64Mode = false;
const remaining: string[] = [];
let flagSelector: string | undefined;
for (let i = 0; i < args.length; i++) {
const inline = /^(--clip|--selector)=(.*)$/s.exec(args[i]);
const flag = inline ? inline[1] : args[i];
const flagValue = () => (inline ? inline[2] : args[++i]);
if (flag === '--viewport') {
viewportOnly = true;
} else if (flag === '--base64') {
base64Mode = true;
} else if (flag === '--selector') {
flagSelector = flagValue();
if (!flagSelector) throw new Error('Usage: screenshot --selector <css> [path]');
} else if (flag === '--clip') {
const coords = flagValue();
if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]');
const parts = coords.split(',').map(Number);
if (parts.length !== 4 || parts.some(isNaN))
throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers');
clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
} else if (args[i].startsWith('--')) {
throw new Error(`Unknown screenshot flag: ${args[i]}`);
} else {
remaining.push(args[i]);
}
}
// Separate target (selector/@ref) from output path
for (const arg of remaining) {
// File paths containing / and ending with an image/pdf extension are never CSS selectors
const isFilePath = arg.includes('/') && /\.(png|jpe?g|webp|pdf)$/i.test(arg);
if (isFilePath) {
outputPath = arg;
} else if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) {
targetSelector = arg;
} else {
outputPath = arg;
}
}
// --selector flag takes precedence; conflict with positional selector.
if (flagSelector !== undefined) {
if (targetSelector !== undefined) {
throw new Error('--selector conflicts with positional selector — choose one');
}
targetSelector = flagSelector;
}
const parsed = parseScreenshotArgs(args);
const { clipRect, targetSelector, viewportOnly, base64Mode } = parsed;
const outputPath = parsed.outputPath ?? `${TEMP_DIR}/browse-screenshot.png`;
validateOutputPath(outputPath);
+43 -9
View File
@@ -8,6 +8,8 @@
import { type RouteEntry, jsonError } from './table';
import { canonicalizeCommand } from '../commands';
import { hasOutArg } from '../read-commands';
import { parseScreenshotArgs } from '../meta-commands';
import { parseSnapshotArgs } from '../snapshot';
import { emitActivity } from '../activity';
import { logTunnelDenial } from '../tunnel-denial-log';
import { sanitizeBody, stripLoneSurrogateEscapes } from '../sanitize';
@@ -24,7 +26,7 @@ export const TUNNEL_COMMANDS = new Set<string>([
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
'scroll', 'press', 'type', 'select', 'wait',
// Tab + navigation primitives operator docs and CLI hints already promised
'newtab', 'tabs', 'back', 'forward', 'reload',
// Read/inspect/write operators paired agents need to be useful
@@ -36,16 +38,48 @@ export const TUNNEL_COMMANDS = new Set<string>([
* Canonicalizes the command (so aliases hit the same set) and returns false
* for null/undefined input.
*
* `args` is consulted so an `--out` invocation (e.g. `eval --out <file>`) is
* NEVER tunnel-dispatchable: `--out` turns an otherwise-readable command into a
* local-disk WRITE, and the tunnel surface never grants disk-write capability to
* remote paired agents. Omitting `args` preserves the old command-only behavior.
* `args` is consulted so that over the tunnel a paired agent can't read local
* files, can't choose a path on the daemon's disk, and can't make the daemon
* save screenshot or snapshot images: `--out`, a screenshot without
* `--base64` or with an output path, a snapshot with `-a`/`-H`/`-o`, and
* goto/newtab to anything but an http(s) URL (file: reads) are refused.
* `eval` is absent from the allowlist because it runs a file from the
* daemon's disk. Omitting `args` (undefined) preserves the old command-only
* behavior; null, a non-array, or an array with a non-string element is refused.
*/
export function canDispatchOverTunnel(command: string | undefined | null, args?: string[]): boolean {
if (typeof command !== 'string' || command.length === 0) return false;
if (Array.isArray(args) && hasOutArg(args)) return false;
const cmd = canonicalizeCommand(command);
return TUNNEL_COMMANDS.has(cmd);
if (!TUNNEL_COMMANDS.has(cmd)) return false;
if (args === undefined) return true;
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string') || hasOutArg(args)) return false;
if (cmd === 'goto' && args.length > 0) return isWebUrl(args[0]);
if (cmd === 'newtab') {
const url = args.find(arg => arg !== '--json');
return url === undefined || isWebUrl(url);
}
try {
if (cmd === 'screenshot') {
const shot = parseScreenshotArgs(args);
return shot.base64Mode && shot.outputPath === undefined;
}
if (cmd === 'snapshot') {
const snap = parseSnapshotArgs(args);
return !snap.outputPath && !snap.annotate && !snap.heatmap;
}
} catch {
return false;
}
return true;
}
function isWebUrl(value: unknown): boolean {
try {
const { protocol } = new URL(String(value));
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
export const commandRoutes: RouteEntry[] = [
@@ -142,10 +176,10 @@ export const commandRoutes: RouteEntry[] = [
// Tunnel surface: only commands in TUNNEL_COMMANDS are allowed.
// Paired remote agents drive the browser but cannot configure the
// daemon, launch new browsers, import cookies, or rotate tokens.
if (surface === 'tunnel' && !canDispatchOverTunnel(body?.command, body?.args)) {
if (surface === 'tunnel' && !canDispatchOverTunnel(body?.command, body?.args === undefined ? [] : body?.args)) {
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
return jsonError(403, `Command '${body?.command}' is not allowed over the tunnel surface`, {
hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Note: --out (disk write) is never allowed over the tunnel.`,
hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Over the tunnel, goto/newtab take http(s) URLs only, screenshot needs --base64 and cannot name an output path (target an element with screenshot --base64 --selector <css>), snapshot refuses -a/--annotate, -H/--heatmap and -o, and --out is never allowed.`,
});
}
return ctx.commands.handle(body, tokenInfo);
+69 -1
View File
@@ -113,7 +113,7 @@ describe('Tunnel command allowlist', () => {
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
'scroll', 'press', 'type', 'select', 'wait',
// Tab + navigation primitives operator docs and CLI hints already promised
'newtab', 'tabs', 'back', 'forward', 'reload',
// Read/inspect/write operators paired agents need to be useful
@@ -247,6 +247,74 @@ describe('/command tunnel command allowlist', () => {
expect(resp.body.hint).toContain('Tunnel commands: ');
}
});
const guarded = [
{ command: 'goto', args: ['file://./.env'] },
{ command: 'newtab', args: ['file:///tmp/page.html'] },
{ command: 'eval', args: ['script.js'] },
{ command: 'screenshot', args: ['assets/logo.png'] },
{ command: 'snapshot', args: ['-a', '-o', 'annotated.png'] },
{ command: 'screenshot', args: [] },
{ command: 'screenshot', args: ['--selector', '.hero'] },
{ command: 'snapshot', args: ['-a'] },
{ command: 'snapshot', args: ['-H', '{"@e1":"green"}'] },
];
function recordingCtx(handled: unknown[]) {
return stubRouteContext({
resetIdleTimer: () => {},
commands: {
handle: async (body) => { handled.push(body); return new Response('{"ok":true}'); },
handleInternal: async () => { throw new Error('unexpected handleInternal'); },
},
});
}
test('tunnel surface refuses non-web navigation, eval, and screenshot output paths before dispatch', async () => {
const handled: unknown[] = [];
for (const body of guarded) {
const resp = await statusAndJson(await callRoute('POST', '/command', recordingCtx(handled), { surface: 'tunnel', body }));
expect(resp.status).toBe(403);
expect(resp.body.error).toBe(`Command '${body.command}' is not allowed over the tunnel surface`);
}
expect(handled).toEqual([]);
});
test('local surface dispatches the same commands unchanged', async () => {
const handled: unknown[] = [];
for (const body of guarded) {
const resp = await callRoute('POST', '/command', recordingCtx(handled), { surface: 'local', body });
expect(resp.status).toBe(200);
}
expect(handled).toEqual(guarded);
});
test('a screenshot with no args field is gated like args: [] (tunnel refuses, local dispatches)', async () => {
const handled: unknown[] = [];
const body = { command: 'screenshot' };
const tunnel = await statusAndJson(await callRoute('POST', '/command', recordingCtx(handled), { surface: 'tunnel', body }));
expect(tunnel.status).toBe(403);
expect(tunnel.body.error).toBe(`Command 'screenshot' is not allowed over the tunnel surface`);
expect(handled).toEqual([]);
const local = await callRoute('POST', '/command', recordingCtx(handled), { surface: 'local', body });
expect(local.status).toBe(200);
expect(handled).toEqual([body]);
});
test('tunnel surface still dispatches web navigation and path-free screenshots', async () => {
const handled: unknown[] = [];
const allowed = [
{ command: 'goto', args: ['https://example.com'] },
{ command: 'newtab', args: ['http://localhost:3000/'] },
{ command: 'screenshot', args: ['--base64'] },
{ command: 'screenshot', args: ['--base64', '--selector', '.hero'] },
{ command: 'snapshot', args: ['-i'] },
];
for (const body of allowed) {
const resp = await callRoute('POST', '/command', recordingCtx(handled), { surface: 'tunnel', body });
expect(resp.status).toBe(200);
}
expect(handled).toEqual(allowed);
});
});
describe('Tunnel listener lifecycle', () => {
+17
View File
@@ -132,6 +132,7 @@ describe('Tab Isolation', () => {
// Test the instruction block generator
import { generateInstructionBlock } from '../src/cli';
import { canDispatchOverTunnel } from '../src/routes/commands';
describe('generateInstructionBlock', () => {
it('generates a valid instruction block with setup key', () => {
@@ -255,6 +256,22 @@ describe('generateInstructionBlock', () => {
expect(block).toContain('"command": "click"');
expect(block).toContain('"command": "fill"');
});
it('every COMMAND REFERENCE example passes the tunnel gate', () => {
const block = generateInstructionBlock({
setupKey: 'gsk_setup_gate',
serverUrl: 'https://test.ngrok.dev',
scopes: ['read', 'write', 'admin', 'meta'],
expiresAt: '2026-04-06T00:00:00Z',
});
const examples = [...block.matchAll(/\{"command": "(\w+)", "args": (\[[^\]]*\])/g)];
expect(examples.length).toBeGreaterThan(5);
for (const [, command, rawArgs] of examples) {
const args = JSON.parse(rawArgs.replace(/"URL"/g, '"https://example.com"'));
expect({ command, args, allowed: canDispatchOverTunnel(command, args) }).toEqual({ command, args, allowed: true });
}
});
});
// Test CLI source-level behavior (pair-agent headed mode, ngrok detection)
+91 -10
View File
@@ -20,14 +20,14 @@ describe('canDispatchOverTunnel — closed allowlist', () => {
}
});
test('TUNNEL_COMMANDS contains the 26-command closed set', () => {
test('TUNNEL_COMMANDS contains the 25-command closed set', () => {
// Mirror the source-level guard in dual-listener.test.ts. If this ever
// disagrees with the literal in server.ts, one of them is wrong.
const expected = new Set([
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
'scroll', 'press', 'type', 'select', 'wait',
'newtab', 'tabs', 'back', 'forward', 'reload',
'snapshot', 'fill', 'url', 'closetab',
]);
@@ -100,30 +100,111 @@ describe('canDispatchOverTunnel — --out writes are never tunnel-dispatchable',
// `--out` turns an otherwise-readable command into a local-disk WRITE. The
// tunnel surface never grants disk-write to remote paired agents, so any
// --out invocation must be 403'd even when the bare command is allowlisted.
test('bare eval dispatches, but eval --out does not', () => {
expect(canDispatchOverTunnel('eval', ['/tmp/x.js'])).toBe(true);
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--out', '/tmp/o.png'])).toBe(false);
test('bare html dispatches, but html --out does not', () => {
expect(canDispatchOverTunnel('html', ['main'])).toBe(true);
expect(canDispatchOverTunnel('html', ['main', '--out', '/tmp/o.html'])).toBe(false);
});
test('--out= form is rejected too (no parser-shape bypass)', () => {
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--out=/tmp/o.png'])).toBe(false);
expect(canDispatchOverTunnel('html', ['main', '--out=/tmp/o.html'])).toBe(false);
});
test('--out anywhere in args is caught regardless of ordering', () => {
expect(canDispatchOverTunnel('eval', ['--out', '/tmp/o.png', '/tmp/x.js'])).toBe(false);
expect(canDispatchOverTunnel('html', ['--out', '/tmp/o.html', 'main'])).toBe(false);
});
test('args without --out still dispatch', () => {
expect(canDispatchOverTunnel('goto', ['https://example.com'])).toBe(true);
expect(canDispatchOverTunnel('eval', ['/tmp/x.js'])).toBe(true);
expect(canDispatchOverTunnel('html', ['main'])).toBe(true);
});
test('omitting args preserves the old command-only behavior', () => {
expect(canDispatchOverTunnel('eval')).toBe(true);
expect(canDispatchOverTunnel('goto')).toBe(true);
});
test('a lookalike flag (--output) is NOT treated as --out', () => {
// hasOutArg matches '--out' exactly or '--out='; '--output' must not trip it.
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--output', '/tmp/o'])).toBe(true);
expect(canDispatchOverTunnel('html', ['main', '--output', '/tmp/o'])).toBe(true);
});
});
describe('canDispatchOverTunnel — tunnel agents stay off the daemon filesystem', () => {
test('goto and newtab accept http(s) URLs', () => {
for (const cmd of ['goto', 'newtab']) {
expect(canDispatchOverTunnel(cmd, ['https://example.com/a?b=c#d'])).toBe(true);
expect(canDispatchOverTunnel(cmd, ['http://localhost:3000/'])).toBe(true);
expect(canDispatchOverTunnel(cmd, ['HTTPS://EXAMPLE.COM'])).toBe(true);
}
});
test('goto and newtab refuse every non-http(s) URL', () => {
const refused = [
'file:///etc/hosts', 'file://./.env', 'file://~/notes.txt', 'FILE:///tmp/x.html',
' file:///tmp/x.html', 'view-source:file:///tmp/x.html', 'data:text/html,hi',
'javascript:alert(1)', 'chrome://version', 'about:blank', 'not a url', '',
];
for (const cmd of ['goto', 'newtab']) {
for (const url of refused) expect(canDispatchOverTunnel(cmd, [url])).toBe(false);
}
});
test('newtab with no URL still dispatches', () => {
expect(canDispatchOverTunnel('newtab', [])).toBe(true);
});
test('newtab gates the URL its handler opens, skipping --json', () => {
expect(canDispatchOverTunnel('newtab', ['--json'])).toBe(true);
expect(canDispatchOverTunnel('newtab', ['--json', 'https://example.com'])).toBe(true);
expect(canDispatchOverTunnel('newtab', ['https://example.com', '--json'])).toBe(true);
expect(canDispatchOverTunnel('newtab', ['--json', 'file:///etc/hosts'])).toBe(false);
});
test('eval (runs a file from the daemon disk) is not reachable over the tunnel', () => {
expect(TUNNEL_COMMANDS.has('eval')).toBe(false);
expect(canDispatchOverTunnel('eval', ['script.js'])).toBe(false);
});
test('screenshot with --base64 and no output path dispatches', () => {
const allowed = [
['--base64'], ['--viewport', '--base64'], ['@e3', '--base64'], ['#main', '--base64'],
['--selector=.hero', '--base64'], ['--clip', '0,0,10,10', '--base64'],
];
for (const args of allowed) expect(canDispatchOverTunnel('screenshot', args)).toBe(true);
});
test('screenshot without --base64 is refused (it would write a temp file)', () => {
const refused = [[], ['--viewport'], ['@e3'], ['--selector', '.hero'], ['--clip', '0,0,10,10']];
for (const args of refused) expect(canDispatchOverTunnel('screenshot', args)).toBe(false);
});
test('screenshot naming an output path is refused', () => {
const refused = [
['/tmp/s.png'], ['assets/logo.png'], ['shot'], ['@e3', 'out.png'],
['--base64', '/tmp/s.png'], ['--clip', '0,0,10,10', 'clip.png'],
];
for (const args of refused) expect(canDispatchOverTunnel('screenshot', args)).toBe(false);
});
test('snapshot naming an output path is refused', () => {
expect(canDispatchOverTunnel('snapshot', ['-i'])).toBe(true);
expect(canDispatchOverTunnel('snapshot', ['-a', '-o', '/tmp/a.png'])).toBe(false);
expect(canDispatchOverTunnel('snapshot', ['--heatmap', '{"@e1":"green"}', '--output', 'heat.png'])).toBe(false);
});
test('annotated and heatmap snapshots are refused (they write temp images)', () => {
for (const args of [['-a'], ['--annotate'], ['-i', '-a'], ['-H', '{"@e1":"green"}'], ['--heatmap', '{"@e1":"green"}']]) {
expect(canDispatchOverTunnel('snapshot', args)).toBe(false);
}
});
test('args that are not an array are refused', () => {
expect(canDispatchOverTunnel('goto', { 0: 'file:///etc/hosts' } as unknown as string[])).toBe(false);
});
test('null args and non-string elements are refused, not treated as omitted', () => {
expect(canDispatchOverTunnel('screenshot', null as unknown as string[])).toBe(false);
expect(canDispatchOverTunnel('goto', [5] as unknown as string[])).toBe(false);
expect(canDispatchOverTunnel('newtab', ['--json', null] as unknown as string[])).toBe(false);
expect(canDispatchOverTunnel('click', ['@e1', { x: 1 }] as unknown as string[])).toBe(false);
});
});
@@ -0,0 +1,4 @@
---
important: security
---
- **Tunnel-paired browse agents stay on web URLs: they can't read local files, can't choose a path on the daemon's disk, and can't make the daemon save screenshot or snapshot images.** Over the `/pair-agent` tunnel, `goto` and `newtab` now accept only http(s) URLs, `screenshot` requires `--base64` (it returns the image) and refuses an output path, `snapshot` refuses `-a`, `-H` and `-o`, and `eval`, which runs a file from the daemon's disk, leaves the tunnel allowlist (now 25 commands). The tunnel's 403 hint names these limits. Local CLI, skill and extension clients behave exactly as before. (GST-1747)
+1 -1
View File
@@ -65,7 +65,7 @@ flows through `POST /pty-session` only. Don't add any token to `/health`.
**Transport-layer security** (v1.6.0.0+). When `pair-agent` starts an ngrok tunnel,
the daemon binds two HTTP listeners: a local listener (127.0.0.1, full command
surface, never forwarded) and a tunnel listener (locked allowlist: `/connect`,
`/command` with a scoped token + 26-command browser-driving allowlist,
`/command` with a scoped token + 25-command browser-driving allowlist,
`/sidebar-chat`). ngrok forwards only the tunnel port. Root tokens over the tunnel
return 403. SSE endpoints use a 30-minute HttpOnly `gstack_sse` cookie minted via
`POST /sse-session` (never valid against `/command`). Tunnel-surface rejections go
+4 -4
View File
@@ -34,7 +34,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` and `/command` (with a scoped token + the 26-command browser-driving allowlist).
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 25-command browser-driving allowlist).
See [ARCHITECTURE.md](../ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) for the full endpoint table.
@@ -105,7 +105,7 @@ loopback Host; 403 otherwise). Not reachable over the tunnel (404).
| `text` | `[]` | Full page text |
| `html` | `["selector?"]` | HTML of element or full page |
| `links` | `[]` | All links on page |
| `screenshot` | `["/tmp/s.png"]` | Take a screenshot |
| `screenshot` | `["--base64"]` | Take a screenshot, returned as a base64 data URL (output paths are refused over the tunnel) |
| `url` | `[]` | Current URL |
#### Interaction
@@ -152,7 +152,7 @@ CSS selectors. Always `snapshot -i` first, then use the refs.
| `meta` | tab, diff, frame, responsive, watch |
| `control` | stop, restart, disconnect, state, handoff — browser-wide destructive ops |
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.
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, and so is `eval` (it runs a file from the daemon's disk). `goto`/`newtab` take http(s) URLs only over the tunnel, `screenshot` needs `--base64`, and `snapshot` refuses `-a`, `-H` and `-o`, so a paired agent can't read local files, can't choose a path on the daemon's disk, and can't make the daemon save screenshot or snapshot images. Pair with `--restrict "read,write"` when the agent will read untrusted web content — scope caps the prompt-injection blast radius.
To tighten an already-paired agent, re-pair it with the **same `--client` name** and the narrower `--restrict`/`--domain`: a reducing re-pair revokes the previous session and releases its tabs immediately (the agent must reconnect with the new key), so the old wide access never lingers. Broadening or refreshing keeps the working session with no outage. Re-pairing without `--client` mints a new agent instead. `root` and names starting with `session:` (kept for local agent sessions) are reserved client names.
@@ -175,7 +175,7 @@ Each agent owns the tabs it creates. Rules:
## Security Model
- **Physical port separation.** Local listener and tunnel listener are separate TCP sockets. ngrok only forwards the tunnel port. Tunnel callers cannot reach bootstrap endpoints at all (404, wrong port).
- **Tunnel command allowlist.** `/command` over the tunnel only accepts 26 browser-driving commands (goto, click, fill, snapshot, text, newtab, tabs, back, forward, reload, closetab, etc.). Server-management commands (tunnel, pair, token, useragent, js) are denied on the tunnel.
- **Tunnel command allowlist.** `/command` over the tunnel only accepts 25 browser-driving commands (goto, click, fill, snapshot, text, newtab, tabs, back, forward, reload, closetab, etc.). Server-management commands (tunnel, pair, token, useragent, js) are denied on the tunnel.
- **Root token is tunnel-blocked.** A request bearing the root token over the tunnel listener returns 403 with a pairing hint. Only scoped session tokens work over the tunnel.
- **Setup keys** expire in 5 minutes and can only be used once.
- **Session tokens** expire in 24 hours (configurable).
+10 -7
View File
@@ -272,7 +272,7 @@ If the value is not `on`, ask via AskUserQuestion (one-way-door posture —
this opens a path from the internet to the local browser):
> "Remote pairing runs an ngrok tunnel from the internet to this machine's
> browser (locked to a 26-command allowlist + scoped token, but still an
> browser (locked to a 25-command allowlist + scoped token, but still an
> exposure). Enable pair-agent on this machine?"
Options: A) Enable — run `~/.claude/skills/gstack/bin/gstack-config set pair_agent on`, confirm it reads back `on`, and continue. B) No — stop here; local pairing (option A above) still works.
@@ -379,21 +379,24 @@ side panel if you have GStack Browser open."
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
- Navigate to http(s) URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
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.
Remote agents go through the tunnel command allowlist, so they can't read local
files, can't choose a path on this machine's disk, and can't make the daemon
save screenshot or snapshot images: `goto`/`newtab` take http(s) URLs only,
`screenshot` needs `--base64` and cannot name an output path, `snapshot`
refuses `-a`, `-H` and `-o`, and
`eval`, `js`, `cookies`, and `storage` are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all of them.
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).
blast radius.
- `--restrict` never grants `control`; that scope stays behind --control.
- To tighten an agent that is ALREADY paired, re-pair it with the **same
`--client` name** and the narrower `--restrict`/`--domain`. A reducing re-pair
+10 -7
View File
@@ -164,7 +164,7 @@ If the value is not `on`, ask via AskUserQuestion (one-way-door posture —
this opens a path from the internet to the local browser):
> "Remote pairing runs an ngrok tunnel from the internet to this machine's
> browser (locked to a 26-command allowlist + scoped token, but still an
> browser (locked to a 25-command allowlist + scoped token, but still an
> exposure). Enable pair-agent on this machine?"
Options: A) Enable — run `~/.claude/skills/gstack/bin/gstack-config set pair_agent on`, confirm it reads back `on`, and continue. B) No — stop here; local pairing (option A above) still works.
@@ -271,21 +271,24 @@ side panel if you have GStack Browser open."
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
- Navigate to http(s) URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
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.
Remote agents go through the tunnel command allowlist, so they can't read local
files, can't choose a path on this machine's disk, and can't make the daemon
save screenshot or snapshot images: `goto`/`newtab` take http(s) URLs only,
`screenshot` needs `--base64` and cannot name an output path, `snapshot`
refuses `-a`, `-H` and `-o`, and
`eval`, `js`, `cookies`, and `storage` are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all of them.
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).
blast radius.
- `--restrict` never grants `control`; that scope stays behind --control.
- To tighten an agent that is ALREADY paired, re-pair it with the **same
`--client` name** and the narrower `--restrict`/`--domain`. A reducing re-pair