diff --git a/browse/src/skill-token.ts b/browse/src/skill-token.ts new file mode 100644 index 00000000..e58f2f61 --- /dev/null +++ b/browse/src/skill-token.ts @@ -0,0 +1,91 @@ +/** + * Skill-token — scoped tokens minted per `$B skill run` invocation. + * + * Why this exists: + * When `$B skill run ` spawns a browser-skill script, the script needs + * to call back into the daemon over loopback HTTP. It MUST NOT receive the + * daemon root token — a script that gets the root token can call any endpoint + * with full authority, defeating the trusted/untrusted distinction. + * + * This module wraps `token-registry.ts` to mint per-spawn session tokens + * bound to read+write scope (the 17-cmd browser-driving surface, minus the + * `eval`/`js`/admin commands that live in the admin scope). The token's + * clientId encodes the skill name and spawn id, so revocation is + * deterministic when the script exits or times out. + * + * Lifecycle: + * spawn start → mintSkillToken() → set GSTACK_SKILL_TOKEN in child env + * ↓ + * script makes HTTP calls /command with Bearer + * ↓ + * spawn exit / timeout → revokeSkillToken() → token invalidated + * + * Why scopes = ['read', 'write']: + * These map to SCOPE_READ + SCOPE_WRITE in token-registry.ts and cover + * navigation, reading, and interaction commands the bulk of skills need. + * Excludes admin (eval/js/cookies/storage) deliberately — agent-authored + * skills should not get arbitrary JS execution. Phase 2 may add an opt-in + * `admin: true` frontmatter flag for cases that genuinely need it, gated + * by stronger review at skillify time. + * + * Zero side effects on import. Safe to import from tests. + */ + +import * as crypto from 'crypto'; +import { createToken, revokeToken, type ScopeCategory, type TokenInfo } from './token-registry'; + +/** Length of TTL slack (in seconds) past the spawn timeout. */ +const TOKEN_TTL_SLACK = 30; + +/** Default scopes for skill tokens. Excludes `admin` (eval/js) and `control`. */ +const DEFAULT_SKILL_SCOPES: ScopeCategory[] = ['read', 'write']; + +/** Generate a fresh spawn id. Caller passes this to spawn AND revoke. */ +export function generateSpawnId(): string { + return crypto.randomBytes(8).toString('hex'); +} + +/** Build the canonical clientId for a skill spawn. */ +export function skillClientId(skillName: string, spawnId: string): string { + return `skill:${skillName}:${spawnId}`; +} + +export interface MintSkillTokenOptions { + skillName: string; + spawnId: string; + /** Spawn timeout in seconds. Token TTL = timeout + 30s slack. */ + spawnTimeoutSeconds: number; + /** + * Override the default scopes. Phase 1 callers should not pass this; reserved + * for future opt-in flags (e.g. an `admin: true` frontmatter for trusted + * human-authored skills that need eval/js). + */ + scopes?: ScopeCategory[]; +} + +/** + * Mint a fresh scoped token for a skill spawn. + * + * Returns the token info; the caller passes `info.token` to the child via the + * GSTACK_SKILL_TOKEN env var. The clientId is deterministic from skillName + + * spawnId so the corresponding `revokeSkillToken()` always finds the right + * record. + */ +export function mintSkillToken(opts: MintSkillTokenOptions): TokenInfo { + const clientId = skillClientId(opts.skillName, opts.spawnId); + return createToken({ + clientId, + scopes: opts.scopes ?? DEFAULT_SKILL_SCOPES, + tabPolicy: 'shared', // skill scripts may switch tabs as needed + rateLimit: 0, // skill scripts can run as fast as the daemon allows + expiresSeconds: opts.spawnTimeoutSeconds + TOKEN_TTL_SLACK, + }); +} + +/** + * Revoke the token for a finished spawn. Idempotent — revoking an already-revoked + * token returns false but is not an error. + */ +export function revokeSkillToken(skillName: string, spawnId: string): boolean { + return revokeToken(skillClientId(skillName, spawnId)); +} diff --git a/browse/test/skill-token.test.ts b/browse/test/skill-token.test.ts new file mode 100644 index 00000000..4aee6c00 --- /dev/null +++ b/browse/test/skill-token.test.ts @@ -0,0 +1,165 @@ +/** + * skill-token tests — verify scoped tokens minted per spawn behave correctly: + * - mint creates a session token bound to the right clientId + * - default scopes are read+write (no admin/control) + * - TTL = spawnTimeout + 30s slack + * - revoke kills the token + * - revoking an already-revoked token is idempotent (returns false) + * - the clientId encoding survives round-trip + * - generated spawn ids are unique + */ + +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + initRegistry, rotateRoot, validateToken, checkScope, +} from '../src/token-registry'; +import { + generateSpawnId, + skillClientId, + mintSkillToken, + revokeSkillToken, +} from '../src/skill-token'; + +describe('skill-token', () => { + beforeEach(() => { + rotateRoot(); + initRegistry('root-token-for-tests'); + }); + + describe('generateSpawnId', () => { + it('returns a hex string', () => { + const id = generateSpawnId(); + expect(id).toMatch(/^[0-9a-f]+$/); + expect(id.length).toBe(16); // 8 bytes -> 16 hex chars + }); + + it('returns unique ids on each call', () => { + const ids = new Set(); + for (let i = 0; i < 50; i++) ids.add(generateSpawnId()); + expect(ids.size).toBe(50); + }); + }); + + describe('skillClientId', () => { + it('encodes skillName + spawnId deterministically', () => { + expect(skillClientId('hackernews-frontpage', 'abc123')).toBe('skill:hackernews-frontpage:abc123'); + }); + }); + + describe('mintSkillToken', () => { + it('mints a session token for the spawn', () => { + const info = mintSkillToken({ + skillName: 'hn-frontpage', + spawnId: 'spawn1', + spawnTimeoutSeconds: 60, + }); + expect(info.token).toStartWith('gsk_sess_'); + expect(info.clientId).toBe('skill:hn-frontpage:spawn1'); + expect(info.type).toBe('session'); + }); + + it('defaults to read+write scopes (no admin)', () => { + const info = mintSkillToken({ + skillName: 'hn-frontpage', + spawnId: 'spawn1', + spawnTimeoutSeconds: 60, + }); + expect(info.scopes).toEqual(['read', 'write']); + expect(info.scopes).not.toContain('admin'); + expect(info.scopes).not.toContain('control'); + }); + + it('TTL is spawnTimeout + 30s slack', () => { + const before = Date.now(); + const info = mintSkillToken({ + skillName: 'x', spawnId: 'y', spawnTimeoutSeconds: 60, + }); + const after = Date.now(); + const expiresMs = new Date(info.expiresAt!).getTime(); + // Token expires ~90s after mint (60s + 30s slack), allow some test fuzz. + expect(expiresMs).toBeGreaterThanOrEqual(before + 90_000 - 1_000); + expect(expiresMs).toBeLessThanOrEqual(after + 90_000 + 1_000); + }); + + it('minted token validates and grants browser-driving scope', () => { + const info = mintSkillToken({ + skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60, + }); + const validated = validateToken(info.token); + expect(validated).not.toBeNull(); + expect(checkScope(validated!, 'goto')).toBe(true); + expect(checkScope(validated!, 'click')).toBe(true); + expect(checkScope(validated!, 'snapshot')).toBe(true); + expect(checkScope(validated!, 'text')).toBe(true); + }); + + it('minted token denies admin commands (eval, js, cookies, storage)', () => { + const info = mintSkillToken({ + skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60, + }); + const validated = validateToken(info.token); + expect(validated).not.toBeNull(); + expect(checkScope(validated!, 'eval')).toBe(false); + expect(checkScope(validated!, 'js')).toBe(false); + expect(checkScope(validated!, 'cookies')).toBe(false); + expect(checkScope(validated!, 'storage')).toBe(false); + }); + + it('minted token denies control commands (state, stop, restart)', () => { + const info = mintSkillToken({ + skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60, + }); + const validated = validateToken(info.token); + expect(checkScope(validated!, 'stop')).toBe(false); + expect(checkScope(validated!, 'restart')).toBe(false); + expect(checkScope(validated!, 'state')).toBe(false); + }); + + it('rateLimit is unlimited (skill scripts run as fast as daemon allows)', () => { + const info = mintSkillToken({ + skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60, + }); + expect(info.rateLimit).toBe(0); + }); + + it('two spawns of the same skill mint distinct tokens', () => { + const a = mintSkillToken({ skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60 }); + const b = mintSkillToken({ skillName: 'hn', spawnId: 's2', spawnTimeoutSeconds: 60 }); + expect(a.token).not.toBe(b.token); + expect(a.clientId).not.toBe(b.clientId); + // Both remain valid until revoked. + expect(validateToken(a.token)).not.toBeNull(); + expect(validateToken(b.token)).not.toBeNull(); + }); + }); + + describe('revokeSkillToken', () => { + it('revokes the token for a given spawn', () => { + const info = mintSkillToken({ skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60 }); + expect(validateToken(info.token)).not.toBeNull(); + + const ok = revokeSkillToken('hn', 's1'); + expect(ok).toBe(true); + expect(validateToken(info.token)).toBeNull(); + }); + + it('idempotent — revoking again returns false (already gone)', () => { + mintSkillToken({ skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60 }); + expect(revokeSkillToken('hn', 's1')).toBe(true); + expect(revokeSkillToken('hn', 's1')).toBe(false); + }); + + it('revoking unknown spawn is a no-op (returns false)', () => { + expect(revokeSkillToken('nonexistent', 'whatever')).toBe(false); + }); + + it('revoking one spawn does not affect a sibling spawn', () => { + const a = mintSkillToken({ skillName: 'hn', spawnId: 's1', spawnTimeoutSeconds: 60 }); + const b = mintSkillToken({ skillName: 'hn', spawnId: 's2', spawnTimeoutSeconds: 60 }); + + expect(revokeSkillToken('hn', 's1')).toBe(true); + expect(validateToken(a.token)).toBeNull(); + expect(validateToken(b.token)).not.toBeNull(); + }); + }); +});