mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
45f951143e
ADR-122 reframed as **RuFlo Browser Substrate**: ruflo becomes the memory, policy, replay, distributed-planning layer underneath browser agents. Stagehand, Browser Use, Playwright, Browserbase, Surfer-H become execution targets — not competitors. Added Architecture section: layered system, 6 runtime services, risk-class taxonomy, Session Capsule schema, production-aware UCT, SOTA rubric. Phase 6 implementation (substrate primitives): - domain/session-capsule.ts — full SessionCapsulePayload with origins[], reusePolicy (allowedOrigins, allowedTaskClasses, maxReplays, requireFreshMfa, allowCrossDevice/Installation), consentProof (signed statement), browserProfile, witnessChainHead, expiry, replays counter. RiskClass enum (read-only / authenticated-read / draft-write / external-submission / financial / account-mutation / destructive) + AUTONOMOUS_CLASSES set. - application/session-capsule-service.ts — create() with PII-scan gate, verify() with schema+signature+trust-list+expiry+maxReplays checks, mount() that increments replays + rolls witness chain head, revoke(), per-class RiskClassifier with verb+target regex taxonomy. - domain/browser-adapter.ts — BrowserExecutionAdapter interface (single surface above the tool wars: open/snapshot/click/fill/type/screenshot/wait/ getUrl/getTitle/getText/close/observe). - infrastructure/agent-browser-execution-adapter.ts — first concrete adapter wraps existing AgentBrowserAdapter; future Phase 6.5+ adds Stagehand, Browserbase, local Chrome. - 20 new tests: clean+PII create, tamper rejection, expired capsule, trust list, mount replays + chain head, maxReplays cap, origin/taskClass policy enforcement, revoke, RiskClassifier for all 7 classes (Class 1-3 autonomous, Class 4-7 human approval), inline-state PII detection regression fix (string-spread bug that prevented SSN regex matching). 218/218 tests passing (+20 for Phase 6). Build + typecheck clean. Beyond SOTA: no public browser-agent system ships a signed-state capsule with origin scope, consent proof, replay counter, witness chain, AND risk-class gating. Stagehand v3 portability + Browserbase persistence + Surfer-H vision are all *execution-layer* — ruflo is the substrate that sits above them. Co-Authored-By: RuFlo <ruv@ruv.net>
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
/**
|
|
* @claude-flow/browser - Session Capsule + Risk Classifier Tests (ADR-122 Phase 6)
|
|
*
|
|
* Acceptance criteria covered:
|
|
* - Capsule with PII in inline state is refused at creation
|
|
* - Clean capsule verifies, mounts, and increments replays
|
|
* - Capsule with maxReplays cap rejects further mounts past the limit
|
|
* - Expired capsule fails verification
|
|
* - Trust-list filter accepts/rejects by signer
|
|
* - Mount enforces allowedOrigins + allowedTaskClasses
|
|
* - Tampered capsule fails signature verification
|
|
* - RiskClassifier produces correct class for representative actions
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { SessionCapsuleService, RiskClassifier } from '../src/application/session-capsule-service.js';
|
|
import { generateWitnessKey } from '../src/infrastructure/witness-signer.js';
|
|
import type { SessionCapsuleEnvelope } from '../src/domain/session-capsule.js';
|
|
|
|
const cleanState = {
|
|
cookies: [{ name: 'sid', value: 'opaque-token-xyz', domain: 'example.com' }],
|
|
localStorage: { theme: 'dark' },
|
|
sessionStorage: {},
|
|
indexedDbMeta: [],
|
|
};
|
|
|
|
const piiState = {
|
|
cookies: [{ name: 'sid', value: 'name=John Doe ssn=123-45-6789', domain: 'example.com' }],
|
|
localStorage: {},
|
|
sessionStorage: {},
|
|
indexedDbMeta: [],
|
|
};
|
|
|
|
describe('SessionCapsuleService', () => {
|
|
describe('create', () => {
|
|
it('seals a clean capsule with verifiable signature', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 'tenant-1',
|
|
ownerId: 'owner-a',
|
|
origins: [{ origin: 'https://example.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState,
|
|
consentStatement: 'I consent to reuse this session for authenticated reads of example.com',
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (!result.success) return;
|
|
expect(result.envelope.payload.capsuleId).toMatch(/^cap-/);
|
|
expect(result.envelope.payload.replays).toBe(0);
|
|
expect(result.envelope.payload.witnessChainHead).toMatch(/^[0-9a-f]{64}$/);
|
|
const verification = svc.verify(result.envelope);
|
|
expect(verification.valid).toBe(true);
|
|
expect(verification.withinPolicy).toBe(true);
|
|
});
|
|
|
|
it('refuses a capsule whose inline state contains PII', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 'tenant-1',
|
|
ownerId: 'owner-a',
|
|
origins: [{ origin: 'https://example.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: piiState,
|
|
consentStatement: 'test',
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.piiCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('defaults reusePolicy.allowedTaskClasses to safe defaults', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 'tenant-1', ownerId: 'o', origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
expect(result.envelope.payload.reusePolicy.allowedTaskClasses).toContain('read-only');
|
|
expect(result.envelope.payload.reusePolicy.allowedTaskClasses).not.toContain('destructive');
|
|
});
|
|
});
|
|
|
|
describe('verify', () => {
|
|
it('rejects expired capsules', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok', ttlMs: 100,
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
const verification = svc.verify(result.envelope, { now: new Date(Date.now() + 60_000) });
|
|
expect(verification.valid).toBe(false);
|
|
expect(verification.reason).toMatch(/expired/);
|
|
});
|
|
|
|
it('rejects tampered capsules', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
const forged: SessionCapsuleEnvelope = JSON.parse(JSON.stringify(result.envelope));
|
|
forged.payload.reusePolicy.allowCrossInstallation = true; // escalate
|
|
const verification = svc.verify(forged);
|
|
expect(verification.valid).toBe(false);
|
|
expect(verification.signatureValid).toBe(false);
|
|
});
|
|
|
|
it('rejects untrusted signers via trust list', async () => {
|
|
const stranger = new SessionCapsuleService({ witnessKey: generateWitnessKey() });
|
|
const result = await stranger.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
const ourVault = new SessionCapsuleService();
|
|
const verification = ourVault.verify(result.envelope, { trustedPublicKeys: ['00'.repeat(32)] });
|
|
expect(verification.valid).toBe(false);
|
|
expect(verification.reason).toMatch(/not in trusted list/);
|
|
});
|
|
});
|
|
|
|
describe('mount', () => {
|
|
it('increments replays and rolls the chain head', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
const before = result.envelope.payload.witnessChainHead;
|
|
const mounted = await svc.mount(result.capsuleId);
|
|
expect(mounted.success).toBe(true);
|
|
if (!mounted.success) return;
|
|
expect(mounted.envelope.payload.replays).toBe(1);
|
|
expect(mounted.envelope.payload.witnessChainHead).not.toBe(before);
|
|
});
|
|
|
|
it('rejects mounts past maxReplays', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
reusePolicy: { maxReplays: 2 },
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
const m1 = await svc.mount(result.capsuleId);
|
|
expect(m1.success).toBe(true);
|
|
const m2 = await svc.mount(result.capsuleId);
|
|
expect(m2.success).toBe(true);
|
|
const m3 = await svc.mount(result.capsuleId);
|
|
expect(m3.success).toBe(false);
|
|
if (m3.success) return;
|
|
expect(m3.reason).toMatch(/maxReplays|replay count/);
|
|
});
|
|
|
|
it('rejects mount on origin not in allowedOrigins', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://example.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
reusePolicy: { allowedOrigins: ['https://example.com'] },
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
const mounted = await svc.mount(result.capsuleId, { targetOrigin: 'https://malicious.com' });
|
|
expect(mounted.success).toBe(false);
|
|
if (mounted.success) return;
|
|
expect(mounted.reason).toMatch(/origin/);
|
|
});
|
|
|
|
it('rejects mount with a task class not in allowedTaskClasses', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
reusePolicy: { allowedTaskClasses: ['read-only'] },
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
const mounted = await svc.mount(result.capsuleId, { taskClass: 'destructive' });
|
|
expect(mounted.success).toBe(false);
|
|
if (mounted.success) return;
|
|
expect(mounted.reason).toMatch(/task class/);
|
|
});
|
|
});
|
|
|
|
describe('revoke', () => {
|
|
it('drops the capsule from the registry', async () => {
|
|
const svc = new SessionCapsuleService();
|
|
const result = await svc.create({
|
|
tenantId: 't', ownerId: 'o',
|
|
origins: [{ origin: 'https://x.com', requireSecure: true, requireHttpOnly: false }],
|
|
inlineState: cleanState, consentStatement: 'ok',
|
|
});
|
|
if (!result.success) throw new Error('expected success');
|
|
expect(svc.revoke(result.capsuleId)).toBe(true);
|
|
expect(svc.get(result.capsuleId)).toBeUndefined();
|
|
const remount = await svc.mount(result.capsuleId);
|
|
expect(remount.success).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('RiskClassifier', () => {
|
|
const classifier = new RiskClassifier();
|
|
|
|
it('classifies destructive verbs as Class 7', () => {
|
|
expect(classifier.classify({ action: 'click', target: 'Delete account', goal: 'delete user' }).class).toBe('destructive');
|
|
});
|
|
|
|
it('classifies financial actions as Class 5', () => {
|
|
expect(classifier.classify({ action: 'click', target: 'Pay invoice', goal: 'submit payment' }).class).toBe('financial');
|
|
});
|
|
|
|
it('classifies account mutation as Class 6', () => {
|
|
expect(classifier.classify({ action: 'fill', target: 'New password', goal: 'change password' }).class).toBe('account-mutation');
|
|
});
|
|
|
|
it('classifies submit clicks as Class 4 external submission', () => {
|
|
expect(classifier.classify({ action: 'click', target: 'Submit form', goal: 'send' }).class).toBe('external-submission');
|
|
});
|
|
|
|
it('classifies fill/type as Class 3 draft write', () => {
|
|
expect(classifier.classify({ action: 'fill', target: '@e1', goal: 'enter email' }).class).toBe('draft-write');
|
|
});
|
|
|
|
it('classifies dashboard reads as Class 2', () => {
|
|
expect(classifier.classify({ action: 'open', target: '/dashboard', goal: 'view dashboard' }).class).toBe('authenticated-read');
|
|
});
|
|
|
|
it('defaults to Class 1 read-only', () => {
|
|
expect(classifier.classify({ action: 'snapshot' }).class).toBe('read-only');
|
|
});
|
|
|
|
it('blocks autonomous execution for Class 4+', () => {
|
|
expect(classifier.isAutonomous(classifier.classify({ action: 'click', target: 'Submit', goal: 'submit form' }))).toBe(false);
|
|
expect(classifier.isAutonomous(classifier.classify({ action: 'click', target: 'Pay', goal: 'pay invoice' }))).toBe(false);
|
|
});
|
|
|
|
it('allows autonomous execution for Class 1-3', () => {
|
|
expect(classifier.isAutonomous(classifier.classify({ action: 'snapshot' }))).toBe(true);
|
|
expect(classifier.isAutonomous(classifier.classify({ action: 'fill', target: '@e1' }))).toBe(true);
|
|
});
|
|
});
|