mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 21:25:27 +02:00
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:
+7
-4
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user