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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-07 18:58:56 -10:00
parent 73f5d0b77d
commit a6e0277fc4
5 changed files with 35 additions and 21 deletions
+7 -4
View File
@@ -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 <your-name>
@@ -591,10 +591,13 @@ function hasFlag(args: string[], flag: string): boolean {
async function handlePairAgent(state: ServerState, args: string[]): Promise<void> {
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<void
},
body: JSON.stringify({
domains,
clientId: clientName,
admin,
control,
...(restrict ? { scopes: restrict.split(',').map(s => s.trim()) } : {}),
}),
signal: AbortSignal.timeout(5000),
});
+6 -3
View File
@@ -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],
+9 -5
View File
@@ -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<ScopeCategory, Set<string>> = {
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,
+4 -4
View File
@@ -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)', () => {
+9 -5
View File
@@ -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);