From a6e0277fc4d54e8d530333616934ca19c699bf5d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 7 Apr 2026 18:58:56 -1000 Subject: [PATCH] feat: default paired agents to full access, split SCOPE_CONTROL The trust boundary for paired agents is the pairing ceremony itself, not the scope. An agent with write scope can already click anything and navigate anywhere. Gating js/cookies behind --admin was security theater. Changes: - Default pair scopes: read+write+admin+meta (was read+write) - New SCOPE_CONTROL for browser-wide destructive ops (stop, restart, disconnect, state, handoff, resume, connect) - --admin flag now grants control scope (backward compat) - New --restrict flag for limited access (e.g., --restrict read) - Updated hint text: "re-pair with --control" instead of "--admin" Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/cli.ts | 11 +++++++---- browse/src/server.ts | 9 ++++++--- browse/src/token-registry.ts | 14 +++++++++----- browse/test/tab-isolation.test.ts | 8 ++++---- browse/test/token-registry.test.ts | 14 +++++++++----- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index bbd5c733..0f6210a2 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -566,7 +566,7 @@ COMMAND REFERENCE: New tab: {"command": "newtab", "args": ["URL"]} SCOPES: ${scopeDesc}. -${scopes.includes('admin') ? '' : `To get admin access (JS, cookies, storage), ask the user to re-pair with --admin.\n`} +${scopes.includes('control') ? '' : `To get browser control access (stop, restart, disconnect), ask the user to re-pair with --control.\n`} TOKEN: Expires ${expiresAt}. Revoke: ask the user to run $B tunnel revoke @@ -591,10 +591,13 @@ function hasFlag(args: string[], flag: string): boolean { async function handlePairAgent(state: ServerState, args: string[]): Promise { const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`; const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim()); - const admin = hasFlag(args, '--admin'); + const control = hasFlag(args, '--control') || hasFlag(args, '--admin'); + const restrict = parseFlag(args, '--restrict'); const localHost = parseFlag(args, '--local'); // Call POST /pair to create a setup key + // Default: full access (read+write+admin+meta). --control adds browser-wide ops. + // --restrict limits: --restrict read (read-only), --restrict "read,write" (no admin) const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, { method: 'POST', headers: { @@ -603,9 +606,9 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise s.trim()) } : {}), }), signal: AbortSignal.timeout(5000), }); diff --git a/browse/src/server.ts b/browse/src/server.ts index 161d079d..4a914561 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1455,9 +1455,12 @@ async function start() { } try { const pairBody = await req.json() as any; - const scopes = pairBody.admin - ? ['read', 'write', 'admin', 'meta'] as const - : (pairBody.scopes || ['read', 'write']) as const; + // Default: full access (read+write+admin+meta). The trust boundary is + // the pairing ceremony itself, not the scope. --control adds browser-wide + // destructive commands (stop, restart, disconnect). --restrict limits scope. + const scopes = pairBody.control || pairBody.admin + ? ['read', 'write', 'admin', 'meta', 'control'] as const + : (pairBody.scopes || ['read', 'write', 'admin', 'meta']) as const; const setupKey = createSetupKey({ clientId: pairBody.clientId, scopes: [...scopes], diff --git a/browse/src/token-registry.ts b/browse/src/token-registry.ts index 8165aae3..3b580005 100644 --- a/browse/src/token-registry.ts +++ b/browse/src/token-registry.ts @@ -50,13 +50,16 @@ export const SCOPE_WRITE = new Set([ 'dialog-accept', 'dialog-dismiss', ]); -/** Dangerous commands — JS execution, credential access, browser-wide mutations */ +/** Page-level power tools — JS execution, credential access, page mutations */ export const SCOPE_ADMIN = new Set([ 'eval', 'js', 'cookies', 'storage', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'style', 'cleanup', 'prettyscreenshot', - // Browser-wide destructive commands (from Codex adversarial finding): +]); + +/** Browser-wide destructive commands — can kill the server, disconnect headed mode */ +export const SCOPE_CONTROL = new Set([ 'state', 'handoff', 'resume', 'stop', 'restart', 'connect', 'disconnect', ]); @@ -66,12 +69,13 @@ export const SCOPE_META = new Set([ 'watch', 'inbox', 'focus', ]); -export type ScopeCategory = 'read' | 'write' | 'admin' | 'meta'; +export type ScopeCategory = 'read' | 'write' | 'admin' | 'meta' | 'control'; const SCOPE_MAP: Record> = { read: SCOPE_READ, write: SCOPE_WRITE, admin: SCOPE_ADMIN, + control: SCOPE_CONTROL, meta: SCOPE_META, }; @@ -170,7 +174,7 @@ export function createToken(opts: CreateTokenOptions): TokenInfo { } = opts; // Validate inputs - const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta']; + const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control']; for (const s of scopes) { if (!validScopes.includes(s as ScopeCategory)) { throw new Error(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`); @@ -297,7 +301,7 @@ export function validateToken(token: string): TokenInfo | null { token: rootToken, clientId: 'root', type: 'session', - scopes: ['read', 'write', 'admin', 'meta'], + scopes: ['read', 'write', 'admin', 'meta', 'control'], tabPolicy: 'shared', rateLimit: 0, // unlimited expiresAt: null, diff --git a/browse/test/tab-isolation.test.ts b/browse/test/tab-isolation.test.ts index 367d4d49..0d9846db 100644 --- a/browse/test/tab-isolation.test.ts +++ b/browse/test/tab-isolation.test.ts @@ -113,15 +113,15 @@ describe('generateInstructionBlock', () => { expect(block).not.toContain('re-pair with --admin'); }); - it('shows re-pair hint when admin not included', () => { + it('shows re-pair hint when control not included', () => { const block = generateInstructionBlock({ - setupKey: 'gsk_setup_nonadmin', + setupKey: 'gsk_setup_nocontrol', serverUrl: 'https://test.ngrok.dev', - scopes: ['read', 'write'], + scopes: ['read', 'write', 'admin', 'meta'], expiresAt: '2026-04-06T00:00:00Z', }); - expect(block).toContain('re-pair with --admin'); + expect(block).toContain('re-pair with --control'); }); it('includes newtab as step 2 (agents must own their tab)', () => { diff --git a/browse/test/token-registry.test.ts b/browse/test/token-registry.test.ts index e272ea18..07c46a63 100644 --- a/browse/test/token-registry.test.ts +++ b/browse/test/token-registry.test.ts @@ -5,7 +5,7 @@ import { validateToken, checkScope, checkDomain, checkRate, revokeToken, rotateRoot, listTokens, recordCommand, serializeRegistry, restoreRegistry, checkConnectRateLimit, - SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_META, + SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META, } from '../src/token-registry'; describe('token-registry', () => { @@ -25,7 +25,7 @@ describe('token-registry', () => { const info = validateToken('root-token-for-tests'); expect(info).not.toBeNull(); expect(info!.clientId).toBe('root'); - expect(info!.scopes).toEqual(['read', 'write', 'admin', 'meta']); + expect(info!.scopes).toEqual(['read', 'write', 'admin', 'meta', 'control']); expect(info!.rateLimit).toBe(0); }); }); @@ -324,7 +324,7 @@ describe('token-registry', () => { it('every command in commands.ts is covered by a scope', () => { // Import the command sets to verify coverage const allInScopes = new Set([ - ...SCOPE_READ, ...SCOPE_WRITE, ...SCOPE_ADMIN, ...SCOPE_META, + ...SCOPE_READ, ...SCOPE_WRITE, ...SCOPE_ADMIN, ...SCOPE_CONTROL, ...SCOPE_META, ]); // chain is a special case (checked via meta scope but dispatches subcommands) allInScopes.add('chain'); @@ -339,8 +339,12 @@ describe('token-registry', () => { expect(SCOPE_ADMIN.has('cookies')).toBe(true); expect(SCOPE_ADMIN.has('storage')).toBe(true); expect(SCOPE_ADMIN.has('useragent')).toBe(true); - expect(SCOPE_ADMIN.has('state')).toBe(true); - expect(SCOPE_ADMIN.has('handoff')).toBe(true); + // Browser-wide destructive commands moved to SCOPE_CONTROL + expect(SCOPE_CONTROL.has('state')).toBe(true); + expect(SCOPE_CONTROL.has('handoff')).toBe(true); + expect(SCOPE_CONTROL.has('stop')).toBe(true); + expect(SCOPE_CONTROL.has('restart')).toBe(true); + expect(SCOPE_CONTROL.has('disconnect')).toBe(true); // Verify safe read commands are NOT in admin expect(SCOPE_ADMIN.has('text')).toBe(false);