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
+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);