This commit is contained in:
341
packages/server/tests/services/evolution-service.test.ts
Normal file
341
packages/server/tests/services/evolution-service.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* H-10 G1 tests — EvolutionService.
|
||||
*
|
||||
* Covers:
|
||||
* - minimum-dataset gate (skips when no target has enough new traces)
|
||||
* - api-key gate (skips when vault is empty)
|
||||
* - target selection (never-run first, then oldest lastRunAt, then most traces)
|
||||
* - baseline resolution (persona + behavioral-spec-section + overrides)
|
||||
* - start/stop/tickInFlight lifecycle
|
||||
* - env flag parsing
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { MindDB, ExecutionTraceStore, EvolutionRunStore } from '@waggle/core';
|
||||
import { BEHAVIORAL_SPEC_SECTIONS } from '@waggle/agent';
|
||||
import {
|
||||
EvolutionService,
|
||||
isEvolutionAutoEnabled,
|
||||
type EvolutionServiceDeps,
|
||||
type EvolutionTargetId,
|
||||
type TickResult,
|
||||
} from '../../src/local/services/evolution-service.js';
|
||||
|
||||
// ── Shared test fixture ──────────────────────────────────────────
|
||||
|
||||
interface Fixture {
|
||||
tmpDir: string;
|
||||
mind: MindDB;
|
||||
traceStore: ExecutionTraceStore;
|
||||
runStore: EvolutionRunStore;
|
||||
apiKey: string | null;
|
||||
activeSpec: Record<string, string>;
|
||||
makeDeps: (overrides?: Partial<EvolutionServiceDeps>) => EvolutionServiceDeps;
|
||||
}
|
||||
|
||||
function setupFixture(): Fixture {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-evo-svc-'));
|
||||
const mind = new MindDB(path.join(tmpDir, 'test.mind'));
|
||||
const traceStore = new ExecutionTraceStore(mind);
|
||||
const runStore = new EvolutionRunStore(mind);
|
||||
|
||||
const fx: Fixture = {
|
||||
tmpDir,
|
||||
mind,
|
||||
traceStore,
|
||||
runStore,
|
||||
apiKey: 'sk-test',
|
||||
activeSpec: {},
|
||||
makeDeps: (overrides = {}) => ({
|
||||
traceStore,
|
||||
runStore,
|
||||
getApiKey: () => fx.apiKey,
|
||||
getActiveBehavioralSpec: () => fx.activeSpec,
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
return fx;
|
||||
}
|
||||
|
||||
function teardown(fx: Fixture): void {
|
||||
fx.mind.close();
|
||||
try { fs.rmSync(fx.tmpDir, { recursive: true, force: true }); } catch { /* EBUSY on win32 */ }
|
||||
}
|
||||
|
||||
/** Seed `count` successful finalized traces for a given personaId. */
|
||||
function seedTraces(
|
||||
fx: Fixture,
|
||||
opts: { personaId?: string; count: number; outcome?: 'success' | 'corrected' | 'pending' },
|
||||
): void {
|
||||
for (let i = 0; i < opts.count; i++) {
|
||||
const id = fx.traceStore.start({
|
||||
personaId: opts.personaId ?? null,
|
||||
input: `q${i}`,
|
||||
});
|
||||
const outcome = opts.outcome ?? 'success';
|
||||
if (outcome !== 'pending') {
|
||||
fx.traceStore.finalize(id, { outcome, output: `a${i}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('EvolutionService — H-10 G1', () => {
|
||||
let fx: Fixture;
|
||||
beforeEach(() => { fx = setupFixture(); });
|
||||
afterEach(() => { teardown(fx); });
|
||||
|
||||
describe('dataset gate + target selection', () => {
|
||||
const targets: EvolutionTargetId[] = [
|
||||
{ kind: 'persona-system-prompt', name: 'coder' },
|
||||
{ kind: 'persona-system-prompt', name: 'writer' },
|
||||
];
|
||||
|
||||
it('pickNextTarget returns null when no persona has enough traces', () => {
|
||||
seedTraces(fx, { personaId: 'coder', count: 5 });
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets, minTracesPerTarget: 20 });
|
||||
expect(svc.pickNextTarget()).toBeNull();
|
||||
});
|
||||
|
||||
it('pickNextTarget picks the never-run target that clears the gate', () => {
|
||||
seedTraces(fx, { personaId: 'coder', count: 25 });
|
||||
seedTraces(fx, { personaId: 'writer', count: 5 });
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets, minTracesPerTarget: 20 });
|
||||
const chosen = svc.pickNextTarget();
|
||||
expect(chosen?.name).toBe('coder');
|
||||
expect(chosen?.newTraces).toBe(25);
|
||||
expect(chosen?.lastRunAt).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers never-run targets over those with an older lastRunAt', () => {
|
||||
// Both qualify by trace count.
|
||||
seedTraces(fx, { personaId: 'coder', count: 30 });
|
||||
seedTraces(fx, { personaId: 'writer', count: 30 });
|
||||
// coder has a historical run; writer has none.
|
||||
fx.runStore.create({
|
||||
targetKind: 'persona-system-prompt',
|
||||
targetName: 'coder',
|
||||
baselineText: 'b', winnerText: 'w',
|
||||
deltaAccuracy: 0.05, gateVerdict: 'pass', gateReasons: [],
|
||||
});
|
||||
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets, minTracesPerTarget: 20 });
|
||||
expect(svc.pickNextTarget()?.name).toBe('writer');
|
||||
});
|
||||
|
||||
it('only counts outcomes in eligibleOutcomes', () => {
|
||||
seedTraces(fx, { personaId: 'coder', count: 30, outcome: 'pending' });
|
||||
const svc = new EvolutionService(fx.makeDeps(), {
|
||||
targets,
|
||||
minTracesPerTarget: 20,
|
||||
eligibleOutcomes: ['success'],
|
||||
});
|
||||
// All pending — 0 eligible even though 30 exist.
|
||||
expect(svc.pickNextTarget()).toBeNull();
|
||||
});
|
||||
|
||||
it('counts only traces created since the last run when computing newTraces', () => {
|
||||
// Seed historical traces first.
|
||||
seedTraces(fx, { personaId: 'coder', count: 100 });
|
||||
|
||||
// Record a run with a created_at that's strictly AFTER the trace rows.
|
||||
// SQLite datetime('now') has second-level precision, so we need to bump
|
||||
// the run's timestamp forward by an hour to avoid a same-second
|
||||
// collision that would make "since >= lastRunAt" include the seeded
|
||||
// traces. Using raw SQL is the least invasive way to simulate time
|
||||
// advancing between seed and run creation.
|
||||
const run = fx.runStore.create({
|
||||
targetKind: 'persona-system-prompt',
|
||||
targetName: 'coder',
|
||||
baselineText: 'b', winnerText: 'w',
|
||||
deltaAccuracy: 0.05, gateVerdict: 'pass', gateReasons: [],
|
||||
});
|
||||
const futureTs = new Date(Date.now() + 60 * 60 * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
|
||||
fx.mind.getDatabase()
|
||||
.prepare('UPDATE evolution_runs SET created_at = ? WHERE run_uuid = ?')
|
||||
.run(futureTs, run.run_uuid);
|
||||
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets, minTracesPerTarget: 20 });
|
||||
const candidates = svc.enumerateCandidates();
|
||||
const coder = candidates.find(c => c.name === 'coder');
|
||||
expect(coder?.lastRunAt).toBe(futureTs);
|
||||
expect(coder?.newTraces).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline resolution', () => {
|
||||
it('resolves a real persona baseline from the registry', () => {
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [] });
|
||||
const baseline = svc.resolveBaseline({ kind: 'persona-system-prompt', name: 'coder' });
|
||||
expect(baseline).toBeTruthy();
|
||||
expect(typeof baseline).toBe('string');
|
||||
});
|
||||
|
||||
it('returns null for an unknown persona', () => {
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [] });
|
||||
expect(svc.resolveBaseline({ kind: 'persona-system-prompt', name: 'nope' })).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the active behavioral spec when an override exists', () => {
|
||||
const section = BEHAVIORAL_SPEC_SECTIONS[0];
|
||||
fx.activeSpec[section] = 'OVERRIDDEN TEXT';
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [] });
|
||||
expect(svc.resolveBaseline({ kind: 'behavioral-spec-section', name: section }))
|
||||
.toBe('OVERRIDDEN TEXT');
|
||||
});
|
||||
|
||||
it('falls back to compile-time BEHAVIORAL_SPEC when no override', () => {
|
||||
const section = BEHAVIORAL_SPEC_SECTIONS[0];
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [] });
|
||||
const baseline = svc.resolveBaseline({ kind: 'behavioral-spec-section', name: section });
|
||||
expect(baseline).toBeTruthy();
|
||||
expect(typeof baseline).toBe('string');
|
||||
});
|
||||
|
||||
it('returns null for tool-description / skill-body / generic (not auto-evolvable)', () => {
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [] });
|
||||
expect(svc.resolveBaseline({ kind: 'tool-description', name: 'anything' })).toBeNull();
|
||||
expect(svc.resolveBaseline({ kind: 'skill-body', name: 'anything' })).toBeNull();
|
||||
expect(svc.resolveBaseline({ kind: 'generic', name: 'anything' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick outcomes', () => {
|
||||
const targets: EvolutionTargetId[] = [
|
||||
{ kind: 'persona-system-prompt', name: 'coder' },
|
||||
];
|
||||
|
||||
it('skips when no API key is configured', async () => {
|
||||
fx.apiKey = null;
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets, minTracesPerTarget: 1 });
|
||||
const result = await svc.tick();
|
||||
expect(result.skipped).toBe(true);
|
||||
if (result.skipped) expect(result.reason).toMatch(/api key/i);
|
||||
});
|
||||
|
||||
it('skips when no target clears the gate', async () => {
|
||||
// Zero traces for any target.
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets, minTracesPerTarget: 20 });
|
||||
const result = await svc.tick();
|
||||
expect(result.skipped).toBe(true);
|
||||
if (result.skipped) expect(result.reason).toMatch(/dataset gate/);
|
||||
});
|
||||
|
||||
it('invokes the runner for the picked target when gate passes', async () => {
|
||||
seedTraces(fx, { personaId: 'coder', count: 25 });
|
||||
const runner = vi.fn<Parameters<NonNullable<EvolutionServiceDeps['runner']>>, Promise<TickResult>>()
|
||||
.mockResolvedValue({
|
||||
skipped: false,
|
||||
targetKind: 'persona-system-prompt',
|
||||
targetName: 'coder',
|
||||
outcome: 'proposed',
|
||||
runUuid: 'uuid-stub',
|
||||
});
|
||||
|
||||
const svc = new EvolutionService(
|
||||
fx.makeDeps({ runner }),
|
||||
{ targets, minTracesPerTarget: 20 },
|
||||
);
|
||||
|
||||
const result = await svc.tick();
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
const [calledTarget, calledBaseline, calledKey] = runner.mock.calls[0];
|
||||
// The service passes the full candidate (incl. lastRunAt + newTraces),
|
||||
// not just the bare target id.
|
||||
expect(calledTarget).toMatchObject({ kind: 'persona-system-prompt', name: 'coder' });
|
||||
expect(calledBaseline).toBeTruthy();
|
||||
expect(calledKey).toBe('sk-test');
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
if (!result.skipped) {
|
||||
expect(result.outcome).toBe('proposed');
|
||||
expect(result.runUuid).toBe('uuid-stub');
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces runner errors through the tick return, not a throw', async () => {
|
||||
seedTraces(fx, { personaId: 'coder', count: 25 });
|
||||
const runner = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const svc = new EvolutionService(
|
||||
fx.makeDeps({ runner }),
|
||||
{ targets, minTracesPerTarget: 20 },
|
||||
);
|
||||
|
||||
const result = await svc.tick();
|
||||
expect(result.skipped).toBe(true);
|
||||
if (result.skipped) expect(result.reason).toMatch(/tick failed: boom/);
|
||||
});
|
||||
|
||||
it('reentrancy guard: concurrent tick returns "already in progress"', async () => {
|
||||
seedTraces(fx, { personaId: 'coder', count: 25 });
|
||||
let resolve: ((v: TickResult) => void) | null = null;
|
||||
const runner = vi.fn(() => new Promise<TickResult>(r => { resolve = r; }));
|
||||
|
||||
const svc = new EvolutionService(
|
||||
fx.makeDeps({ runner }),
|
||||
{ targets, minTracesPerTarget: 20 },
|
||||
);
|
||||
|
||||
const first = svc.tick();
|
||||
const second = await svc.tick(); // before first resolves
|
||||
|
||||
expect(second.skipped).toBe(true);
|
||||
if (second.skipped) expect(second.reason).toMatch(/already in progress/);
|
||||
|
||||
// Unblock and let the first finish cleanly.
|
||||
resolve!({
|
||||
skipped: false,
|
||||
targetKind: 'persona-system-prompt',
|
||||
targetName: 'coder',
|
||||
outcome: 'proposed',
|
||||
runUuid: null,
|
||||
});
|
||||
await first;
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('start/stop toggles isRunning', () => {
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [], tickIntervalMs: 60_000 });
|
||||
expect(svc.isRunning()).toBe(false);
|
||||
svc.start();
|
||||
expect(svc.isRunning()).toBe(true);
|
||||
svc.stop();
|
||||
expect(svc.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('start is idempotent — double-start leaves a single timer', () => {
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [], tickIntervalMs: 60_000 });
|
||||
svc.start();
|
||||
const firstTimer = (svc as unknown as { timer: NodeJS.Timeout | null }).timer;
|
||||
svc.start();
|
||||
const secondTimer = (svc as unknown as { timer: NodeJS.Timeout | null }).timer;
|
||||
expect(firstTimer).toBe(secondTimer);
|
||||
svc.stop();
|
||||
});
|
||||
|
||||
it('clamps tickIntervalMs to at least 60s', () => {
|
||||
const svc = new EvolutionService(fx.makeDeps(), { targets: [], tickIntervalMs: 1 });
|
||||
expect(svc.config.tickIntervalMs).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEvolutionAutoEnabled', () => {
|
||||
it('returns false when the flag is missing or unrelated', () => {
|
||||
expect(isEvolutionAutoEnabled({})).toBe(false);
|
||||
expect(isEvolutionAutoEnabled({ WAGGLE_EVOLUTION_AUTO_ENABLED: '' })).toBe(false);
|
||||
expect(isEvolutionAutoEnabled({ WAGGLE_EVOLUTION_AUTO_ENABLED: 'false' })).toBe(false);
|
||||
expect(isEvolutionAutoEnabled({ WAGGLE_EVOLUTION_AUTO_ENABLED: '0' })).toBe(false);
|
||||
});
|
||||
it('returns true for "1" / "true" / "yes"', () => {
|
||||
expect(isEvolutionAutoEnabled({ WAGGLE_EVOLUTION_AUTO_ENABLED: '1' })).toBe(true);
|
||||
expect(isEvolutionAutoEnabled({ WAGGLE_EVOLUTION_AUTO_ENABLED: 'true' })).toBe(true);
|
||||
expect(isEvolutionAutoEnabled({ WAGGLE_EVOLUTION_AUTO_ENABLED: 'TRUE' })).toBe(true);
|
||||
expect(isEvolutionAutoEnabled({ WAGGLE_EVOLUTION_AUTO_ENABLED: 'yes' })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolvePermission,
|
||||
filterByPermissions,
|
||||
getDefaultPolicies,
|
||||
riskExceedsThreshold,
|
||||
type EffectivePermissions,
|
||||
} from '../../src/services/team-capability-governance.js';
|
||||
|
||||
function makePerms(overrides?: Partial<EffectivePermissions>): EffectivePermissions {
|
||||
return {
|
||||
role: 'member',
|
||||
allowedSources: ['native', 'skill'],
|
||||
blockedTools: ['bash', 'delete_skill'],
|
||||
approvalThreshold: 'medium',
|
||||
overrides: new Map(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('riskExceedsThreshold', () => {
|
||||
it('high exceeds medium', () => {
|
||||
expect(riskExceedsThreshold('high', 'medium')).toBe(true);
|
||||
});
|
||||
|
||||
it('medium does not exceed medium', () => {
|
||||
expect(riskExceedsThreshold('medium', 'medium')).toBe(false);
|
||||
});
|
||||
|
||||
it('low does not exceed medium', () => {
|
||||
expect(riskExceedsThreshold('low', 'medium')).toBe(false);
|
||||
});
|
||||
|
||||
it('nothing exceeds none threshold', () => {
|
||||
expect(riskExceedsThreshold('high', 'none')).toBe(false);
|
||||
expect(riskExceedsThreshold('medium', 'none')).toBe(false);
|
||||
expect(riskExceedsThreshold('low', 'none')).toBe(false);
|
||||
});
|
||||
|
||||
it('medium exceeds low', () => {
|
||||
expect(riskExceedsThreshold('medium', 'low')).toBe(true);
|
||||
});
|
||||
|
||||
// P7/D15 A2b regression: 'critical' was omitted from the local risk map, so
|
||||
// `?? 0` sorted it BELOW 'low' and a critical capability slipped past a 'low'
|
||||
// approval threshold. Now ranked on the canonical scale.
|
||||
it('critical exceeds every lower threshold', () => {
|
||||
expect(riskExceedsThreshold('critical', 'low')).toBe(true);
|
||||
expect(riskExceedsThreshold('critical', 'medium')).toBe(true);
|
||||
expect(riskExceedsThreshold('critical', 'high')).toBe(true);
|
||||
});
|
||||
|
||||
it('critical does not exceed critical', () => {
|
||||
expect(riskExceedsThreshold('critical', 'critical')).toBe(false);
|
||||
});
|
||||
|
||||
it('nothing exceeds the none threshold, including critical', () => {
|
||||
expect(riskExceedsThreshold('critical', 'none')).toBe(false);
|
||||
});
|
||||
|
||||
it('low does not exceed low', () => {
|
||||
expect(riskExceedsThreshold('low', 'low')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePermission', () => {
|
||||
it('override approved returns allowed immediately', () => {
|
||||
const perms = makePerms({
|
||||
overrides: new Map([['dangerous_tool', 'approved']]),
|
||||
});
|
||||
// Even if source not in allowedSources, override wins
|
||||
expect(resolvePermission(perms, 'dangerous_tool', 'plugin', 'high')).toBe('allowed');
|
||||
});
|
||||
|
||||
it('override blocked returns blocked immediately', () => {
|
||||
const perms = makePerms({
|
||||
overrides: new Map([['some_skill', 'blocked']]),
|
||||
});
|
||||
// Even if source is allowed and tool not blocked, override wins
|
||||
expect(resolvePermission(perms, 'some_skill', 'native', 'low')).toBe('blocked');
|
||||
});
|
||||
|
||||
it('source not allowed returns source_not_allowed', () => {
|
||||
const perms = makePerms({ allowedSources: ['native'] });
|
||||
expect(resolvePermission(perms, 'some_plugin', 'plugin', 'low')).toBe('source_not_allowed');
|
||||
});
|
||||
|
||||
it('blocked tool returns blocked', () => {
|
||||
const perms = makePerms();
|
||||
expect(resolvePermission(perms, 'bash', 'native', 'low')).toBe('blocked');
|
||||
});
|
||||
|
||||
it('risk exceeds threshold returns needs_approval', () => {
|
||||
const perms = makePerms({ approvalThreshold: 'medium' });
|
||||
expect(resolvePermission(perms, 'safe_tool', 'native', 'high')).toBe('needs_approval');
|
||||
});
|
||||
|
||||
it('risk within threshold returns allowed', () => {
|
||||
const perms = makePerms({ approvalThreshold: 'medium' });
|
||||
expect(resolvePermission(perms, 'safe_tool', 'native', 'low')).toBe('allowed');
|
||||
});
|
||||
|
||||
it('threshold none means everything allowed (no approval needed)', () => {
|
||||
const perms = makePerms({ approvalThreshold: 'none' });
|
||||
expect(resolvePermission(perms, 'safe_tool', 'native', 'high')).toBe('allowed');
|
||||
});
|
||||
|
||||
it('blocked tool takes priority over source check', () => {
|
||||
const perms = makePerms({
|
||||
allowedSources: ['native'],
|
||||
blockedTools: ['bash'],
|
||||
});
|
||||
// bash is native (allowed source) but blocked tool — should be blocked
|
||||
expect(resolvePermission(perms, 'bash', 'native', 'low')).toBe('blocked');
|
||||
});
|
||||
|
||||
it('defaults risk to low when not provided', () => {
|
||||
const perms = makePerms({ approvalThreshold: 'low' });
|
||||
// low does not exceed low, so allowed
|
||||
expect(resolvePermission(perms, 'safe_tool', 'native')).toBe('allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByPermissions', () => {
|
||||
it('removes blocked capabilities', () => {
|
||||
const perms = makePerms();
|
||||
const caps = [
|
||||
{ name: 'bash', type: 'native' },
|
||||
{ name: 'safe_tool', type: 'native', risk: 'low' },
|
||||
];
|
||||
const results = filterByPermissions(perms, caps);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].result).toBe('blocked');
|
||||
expect(results[1].result).toBe('allowed');
|
||||
});
|
||||
|
||||
it('keeps approved overrides even if source not in policy', () => {
|
||||
const perms = makePerms({
|
||||
allowedSources: ['native'],
|
||||
overrides: new Map([['my_plugin', 'approved']]),
|
||||
});
|
||||
const caps = [{ name: 'my_plugin', type: 'plugin', risk: 'high' }];
|
||||
const results = filterByPermissions(perms, caps);
|
||||
expect(results[0].result).toBe('allowed');
|
||||
});
|
||||
|
||||
it('blocks override-blocked even if policy would allow', () => {
|
||||
const perms = makePerms({
|
||||
allowedSources: ['native', 'skill'],
|
||||
blockedTools: [],
|
||||
overrides: new Map([['good_skill', 'blocked']]),
|
||||
});
|
||||
const caps = [{ name: 'good_skill', type: 'skill', risk: 'low' }];
|
||||
const results = filterByPermissions(perms, caps);
|
||||
expect(results[0].result).toBe('blocked');
|
||||
});
|
||||
|
||||
it('marks source_not_allowed for unknown sources', () => {
|
||||
const perms = makePerms({ allowedSources: ['native'] });
|
||||
const caps = [{ name: 'mcp_tool', type: 'mcp' }];
|
||||
const results = filterByPermissions(perms, caps);
|
||||
expect(results[0].result).toBe('source_not_allowed');
|
||||
});
|
||||
|
||||
it('marks needs_approval when risk exceeds threshold', () => {
|
||||
const perms = makePerms({ approvalThreshold: 'low' });
|
||||
const caps = [{ name: 'risky_skill', type: 'skill', risk: 'medium' }];
|
||||
const results = filterByPermissions(perms, caps);
|
||||
expect(results[0].result).toBe('needs_approval');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultPolicies', () => {
|
||||
it('returns 3 role policies', () => {
|
||||
const policies = getDefaultPolicies();
|
||||
expect(policies).toHaveLength(3);
|
||||
const roles = policies.map((p) => p.role);
|
||||
expect(roles).toContain('owner');
|
||||
expect(roles).toContain('admin');
|
||||
expect(roles).toContain('member');
|
||||
});
|
||||
|
||||
it('owner has all sources and no blocks', () => {
|
||||
const owner = getDefaultPolicies().find((p) => p.role === 'owner')!;
|
||||
expect(owner.allowedSources).toEqual(['native', 'skill', 'plugin', 'mcp']);
|
||||
expect(owner.blockedTools).toEqual([]);
|
||||
expect(owner.approvalThreshold).toBe('none');
|
||||
});
|
||||
|
||||
it('admin has all sources and no blocks', () => {
|
||||
const admin = getDefaultPolicies().find((p) => p.role === 'admin')!;
|
||||
expect(admin.allowedSources).toEqual(['native', 'skill', 'plugin', 'mcp']);
|
||||
expect(admin.blockedTools).toEqual([]);
|
||||
expect(admin.approvalThreshold).toBe('none');
|
||||
});
|
||||
|
||||
it('member has restricted sources and blocked tools', () => {
|
||||
const member = getDefaultPolicies().find((p) => p.role === 'member')!;
|
||||
expect(member.allowedSources).toEqual(['native', 'skill']);
|
||||
expect(member.blockedTools).toEqual(['bash', 'delete_skill']);
|
||||
expect(member.approvalThreshold).toBe('medium');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user