mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
e111bfbd7f
* fix(memory,tests): restore `memory search` recall and green the CLI suite (69 → 0) The CLI suite had 69 failing tests across 50 files. Root-causing each rather than adjusting expectations turned up one real product regression, two real bugs, and a set of tests asserting things about the machine instead of the code. PRODUCT REGRESSION — `memory search` recalled nothing (re-break of #2558) `memory search` returned zero results for content that matched word for word, while store/list/retrieve worked. Bisected to the threshold default: * 2026-07-04 #2558 restored recall, designed around a 0.3 threshold. Its fusion scores a full-coverage keyword hit as 0.6*max(0,semantic) + 0.4*lexical, so with a non-positive cosine — routine for a one-word query — a PERFECT keyword match tops out at exactly 0.40. * 2026-07-26 #2790 set the CLI default to 0.7. Above 0.4, keyword recall is mathematically unreachable, so #2558 silently regressed. Instrumented the live path to confirm before changing anything: key=note/alpha terms=["connectivity"] cov=1 bm25=0.029 sem=-0.883 lex=1.000 score=0.400 thr=0.7 → dropped Default restored to 0.3 at both sites, with the ceiling documented so it is not raised past 0.4 again. Verified end to end: a shared keyword recalls all three entries, a unique keyword recalls only its own. This was broken on main too. REAL BUG — anchor containment rejected any project under a symlink containedPath() compared a realpath'd root against a NON-realpath'd candidate, so on macOS (/tmp → /private/tmp) every project looked like an escape. Now compares like with like. Both guarantees re-verified by probe: ../ traversal, deep traversal, absolute-outside and symlink-escape all still rejected; symlinked-root spelling now accepted. REAL BUG — a test that could not fail funnel.test.ts asserted `elapsed < 100ms` as a proxy for "no network call" — its own comment conceded a future fetch() would still pass. It also failed intermittently (118ms) under full-suite CPU contention. Replaced with an actual assertion on fetch/http.request/https.request, patched via the CJS copies (ESM namespaces are non-writable). Negative-controlled: the technique observes a real call and restores cleanly. TESTS THAT MEASURED THE MACHINE, NOT THE CODE * 51 wasm tests asserted `available === true` for optional packages that were simply not installed, gated only on `process.env.CI`. Now also gated on real availability. The 7 tests in those files that need no WASM were lifted into their own blocks so they keep running rather than being skipped along with the rest — no coverage traded away for a green tick. * 4 agenticow tests lacked the `skipIf(!havePkg)` guard their 4 siblings had; in degraded mode the verb is inert and returns before validation. * policy-runtime hashed the raw tmpdir path while the source hashes the CANONICAL one, so it looked for a trust anchor that is never written there — and its cleanup was deleting a nonexistent path, leaking real anchors into ~/.config on every run. * sona's provider allowlist predated the ruvector / wasm-embedder / @claude-flow/embeddings backends. * ruvector/index asserted vi.mock satisfies a *dynamic* import() of a bare specifier. It does not under vitest 4 — the specifier is rewritten to '/@id/@ruvector/core' and fails with ERR_MODULE_NOT_FOUND. Now pins the contract the try/catch actually provides: unloadable → false, never throws. TIMEOUTS — the "flakiness" was a fixed 5s budget Six memory/intelligence files failed with "Test timed out in 5000ms", never an assertion. They initialise a real ONNX embedder and SQLite; alone they are fast, but the full suite saturates every core (~440% CPU). Because it depended on scheduling, a different file failed each run. Raised testTimeout/hookTimeout to 30s. (Process-level isolation was tried first and ruled out — forks changed nothing, which is what identified the cause as time, not shared state.) Verified: 3 consecutive full runs at 0 failed / 3572 passed on the feature base, and 0 failed / 3537 passed on this branch's base. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 * fix(tests): make ruvector availability assertion environment-independent CI caught what my first pass missed. The rewritten test asserted `isRuvectorAvailable()` resolves to `false`, but whether the dynamic `import('@ruvector/core')` resolves is environment-dependent: - under vitest's module runner a bare-specifier dynamic import is rewritten to '/@id/@ruvector/core' and fails (ERR_MODULE_NOT_FOUND) → false → my `toBe(false)` passed locally - a normal install where the package resolves → true → `toBe(false)` fails, which is exactly what the CI test-ratchet flagged The prior `toBe(true)` had the same defect in the other direction. Pin the invariant that holds in BOTH environments instead — the only thing the try/catch actually promises: it resolves to a boolean and never throws. No specific resolution outcome is asserted. Verified 33/33 locally; the sibling "should return boolean" test already covers the type, so this now documents the no-throw contract distinctly. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync, realpathSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir, userInfo } from 'node:os';
|
|
import { createHash, createHmac } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
autoMigratePolicyStateIfNeeded,
|
|
classifyMcpTool,
|
|
evaluatePolicyRequest,
|
|
issuePolicyApproval,
|
|
loadPolicyState,
|
|
setPolicyBudget,
|
|
setPolicyMode,
|
|
upsertPolicyRule,
|
|
verifyPolicyLedger,
|
|
} from '../src/services/policy-runtime.js';
|
|
import { callMCPTool } from '../src/mcp-client.js';
|
|
|
|
const roots: Array<{ root: string; trust: string }> = [];
|
|
function project(): string {
|
|
const root = mkdtempSync(join(tmpdir(), 'ruflo-policy-runtime-'));
|
|
mkdirSync(join(root, '.claude-flow'), { recursive: true });
|
|
// policy-runtime derives the trust-anchor id from the CANONICAL root
|
|
// (realpathSync), so hash the same thing here — on macOS tmpdir() sits
|
|
// behind a symlink, so hashing the raw path pointed this test (and its
|
|
// cleanup) at a directory that is never created, leaking real anchors.
|
|
const projectId = createHash('sha256').update(realpathSync(root)).digest('hex');
|
|
roots.push({
|
|
root,
|
|
trust: join(userInfo().homedir, '.config', 'ruflo', 'policy-trust', projectId),
|
|
});
|
|
return root;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const item of roots.splice(0)) {
|
|
rmSync(item.trust, { recursive: true, force: true });
|
|
rmSync(item.root, { recursive: true, force: true });
|
|
}
|
|
delete process.env.CLAUDE_FLOW_POLICY_APPROVERS;
|
|
delete process.env.CLAUDE_FLOW_CAPABILITY_ENVELOPE;
|
|
});
|
|
|
|
describe('policy runtime compatibility and transactions', () => {
|
|
it('derives security attributes from trusted tool identity', () => {
|
|
expect(classifyMcpTool('memory_search')).toMatchObject({
|
|
actionType: 'memory.read',
|
|
namespaceAccess: 'read',
|
|
destructive: false,
|
|
});
|
|
expect(classifyMcpTool('memory_delete')).toMatchObject({
|
|
actionType: 'memory.write',
|
|
namespaceAccess: 'write',
|
|
destructive: true,
|
|
});
|
|
expect(classifyMcpTool('policy_rule_upsert')).toMatchObject({
|
|
actionType: 'policy.admin.rule_upsert',
|
|
destructive: true,
|
|
});
|
|
});
|
|
|
|
it('does not exempt policy administration from the MCP chokepoint', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await setPolicyMode('enforce', root);
|
|
await expect(callMCPTool('policy_rule_upsert', {
|
|
rule: { id: 'self-allow', effect: 'allow', actions: ['*'] },
|
|
projectRoot: root,
|
|
}, {
|
|
projectRoot: root,
|
|
principalId: 'agent:untrusted',
|
|
principalType: 'agent',
|
|
})).rejects.toThrow('MCP tool not found');
|
|
expect(loadPolicyState(root).rules).toHaveLength(0);
|
|
});
|
|
|
|
it('enforces a spawned worker capability envelope at MCP dispatch', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await upsertPolicyRule({ id: 'allow-tools', effect: 'allow', actions: ['mcp.tool.call'] }, root);
|
|
await setPolicyMode('enforce', root);
|
|
process.env.CLAUDE_FLOW_CAPABILITY_ENVELOPE = JSON.stringify({
|
|
actions: ['*'],
|
|
resources: ['*'],
|
|
tools: ['*'],
|
|
network: false,
|
|
destructive: false,
|
|
maxConcurrency: 1,
|
|
});
|
|
await expect(callMCPTool('terminal_execute', {
|
|
command: 'echo should-not-run',
|
|
}, { projectRoot: root })).rejects.toThrow('network-outside-envelope');
|
|
});
|
|
|
|
it('uses parent policy from an isolated git worktree', async () => {
|
|
const root = project();
|
|
execFileSync('git', ['init', '-q', root]);
|
|
writeFileSync(join(root, 'README.md'), 'policy worktree test\n');
|
|
execFileSync('git', ['-C', root, 'add', 'README.md']);
|
|
execFileSync('git', [
|
|
'-C', root, '-c', 'user.name=Ruflo Test', '-c', 'user.email=test@invalid',
|
|
'commit', '-qm', 'initial',
|
|
]);
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await upsertPolicyRule({ id: 'allow-tools', effect: 'allow', actions: ['mcp.tool.call'] }, root);
|
|
await setPolicyMode('enforce', root);
|
|
|
|
const worktree = `${root}-worker`;
|
|
execFileSync('git', ['-C', root, 'worktree', 'add', '--detach', worktree, 'HEAD']);
|
|
const previousCwd = process.cwd();
|
|
process.env.CLAUDE_FLOW_CAPABILITY_ENVELOPE = JSON.stringify({
|
|
actions: ['*'],
|
|
resources: ['*'],
|
|
tools: ['*'],
|
|
network: false,
|
|
destructive: false,
|
|
});
|
|
try {
|
|
process.chdir(worktree);
|
|
await expect(callMCPTool('terminal_execute', {
|
|
command: 'echo should-not-run',
|
|
})).rejects.toThrow('network-outside-envelope');
|
|
expect(loadPolicyState(root).receipts).toHaveLength(1);
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
execFileSync('git', ['-C', root, 'worktree', 'remove', '--force', worktree]);
|
|
}
|
|
});
|
|
|
|
it('auto-migrates an existing installation once in legacy mode', async () => {
|
|
const root = project();
|
|
expect(await autoMigratePolicyStateIfNeeded(root)).toMatchObject({ migrated: true, mode: 'legacy' });
|
|
expect(await autoMigratePolicyStateIfNeeded(root)).toMatchObject({ migrated: false, mode: 'legacy' });
|
|
expect(loadPolicyState(root).migratedFrom).toContain('pre-ADR-324');
|
|
});
|
|
|
|
it('applies Codex policy mode changes without overwriting later CLI transitions', async () => {
|
|
const root = project();
|
|
mkdirSync(join(root, '.agents'), { recursive: true });
|
|
const config = join(root, '.agents', 'config.toml');
|
|
writeFileSync(config, '[policy]\nmode = "observe"\n');
|
|
expect(await autoMigratePolicyStateIfNeeded(root)).toMatchObject({ mode: 'observe' });
|
|
|
|
await setPolicyMode('enforce', root);
|
|
expect(await autoMigratePolicyStateIfNeeded(root)).toMatchObject({ mode: 'enforce' });
|
|
|
|
writeFileSync(config, '[policy]\nmode = "legacy"\n');
|
|
expect(await autoMigratePolicyStateIfNeeded(root)).toMatchObject({ mode: 'enforce' });
|
|
});
|
|
|
|
it('fails closed when enforced policy state is modified or removed', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await setPolicyMode('enforce', root);
|
|
const statePath = join(root, '.claude-flow', 'policy', 'state.json');
|
|
expect(existsSync(roots.at(-1)!.trust)).toBe(true);
|
|
|
|
const original = readFileSync(statePath, 'utf8');
|
|
const changed = JSON.parse(original) as { mode: string };
|
|
changed.mode = 'legacy';
|
|
writeFileSync(statePath, JSON.stringify(changed));
|
|
expect(() => loadPolicyState(root)).toThrow('policy-state-authentication-failed');
|
|
|
|
writeFileSync(statePath, original);
|
|
expect(loadPolicyState(root).mode).toBe('enforce');
|
|
unlinkSync(statePath);
|
|
expect(() => loadPolicyState(root)).toThrow('policy-state-missing-for-anchored-project');
|
|
});
|
|
|
|
it('binds the trust anchor to the canonical project path', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await setPolicyMode('enforce', root);
|
|
const alias = `${root}-alias`;
|
|
symlinkSync(root, alias);
|
|
try {
|
|
expect(loadPolicyState(alias).mode).toBe('enforce');
|
|
} finally {
|
|
unlinkSync(alias);
|
|
}
|
|
});
|
|
|
|
it('does not treat a local issuer string as authenticated human authority', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await expect(issuePolicyApproval({
|
|
id: 'forged-local-approval',
|
|
principal: 'agent:release',
|
|
actions: ['deployment.promote'],
|
|
issuedBy: 'user:claimed-admin',
|
|
expiresAt: Date.now() + 60_000,
|
|
maxUses: 1,
|
|
}, root)).rejects.toThrow('untrusted-approval-issuer');
|
|
});
|
|
|
|
it('moves from observation to enforcement without changing rule semantics', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await upsertPolicyRule({ id: 'allow-read', effect: 'allow', actions: ['code.read'] }, root);
|
|
await setPolicyMode('observe', root);
|
|
const writeObserved = await evaluatePolicyRequest({
|
|
identity: { id: 'agent-1', type: 'agent' },
|
|
action: { type: 'code.write', resource: 'src/a.ts' },
|
|
}, root);
|
|
expect(writeObserved.outcome).toBe('denied');
|
|
expect(writeObserved.enforcedOutcome).toBe('allowed');
|
|
|
|
await setPolicyMode('enforce', root);
|
|
const read = await evaluatePolicyRequest({
|
|
identity: { id: 'agent-1', type: 'agent' },
|
|
action: { type: 'code.read', resource: 'src/a.ts' },
|
|
}, root);
|
|
expect(read.enforcedOutcome).toBe('allowed');
|
|
const write = await evaluatePolicyRequest({
|
|
identity: { id: 'agent-1', type: 'agent' },
|
|
action: { type: 'code.write', resource: 'src/a.ts' },
|
|
}, root);
|
|
expect(write.enforcedOutcome).toBe('denied');
|
|
expect(await verifyPolicyLedger(root)).toEqual({ valid: true, length: 3 });
|
|
});
|
|
|
|
it('cryptographically binds signed evidence provenance claims', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await upsertPolicyRule({
|
|
id: 'verified-release',
|
|
effect: 'allow',
|
|
actions: ['deployment.promote'],
|
|
constraints: { requireSignedEvidence: true, requiredProvenance: ['tool_result'] },
|
|
}, root);
|
|
await setPolicyMode('enforce', root);
|
|
const key = 'test-evidence-key-material';
|
|
process.env.CLAUDE_FLOW_POLICY_EVIDENCE_KEYS = JSON.stringify({ ci: key });
|
|
const evidence = {
|
|
id: 'evidence-1',
|
|
provenance: 'tool_result' as const,
|
|
attestor: 'ci',
|
|
observedAt: 100,
|
|
contentHash: `sha256:${'a'.repeat(64)}`,
|
|
keyId: 'ci',
|
|
};
|
|
const claims = JSON.stringify(evidence);
|
|
const signature = `hmac-sha256:${createHmac('sha256', key).update(claims).digest('hex')}`;
|
|
try {
|
|
expect((await evaluatePolicyRequest({
|
|
identity: { id: 'agent:release', type: 'agent' },
|
|
action: { type: 'deployment.promote' },
|
|
context: { evidence: [{ ...evidence, signature }] },
|
|
}, root)).enforcedOutcome).toBe('allowed');
|
|
expect((await evaluatePolicyRequest({
|
|
identity: { id: 'agent:release', type: 'agent' },
|
|
action: { type: 'deployment.promote' },
|
|
context: {
|
|
evidence: [{ ...evidence, provenance: 'user_claim', signature }],
|
|
},
|
|
}, root)).enforcedOutcome).toBe('denied');
|
|
} finally {
|
|
delete process.env.CLAUDE_FLOW_POLICY_EVIDENCE_KEYS;
|
|
}
|
|
});
|
|
|
|
it('serializes concurrent decisions into one valid receipt chain', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await Promise.all(Array.from({ length: 20 }, (_, index) => evaluatePolicyRequest({
|
|
identity: { id: `agent:${index}`, type: 'agent' },
|
|
action: { type: 'code.read', resource: `file-${index}` },
|
|
}, root)));
|
|
expect(await verifyPolicyLedger(root)).toEqual({ valid: true, length: 20 });
|
|
});
|
|
|
|
it('cannot overspend a budget under concurrent decisions', async () => {
|
|
const root = project();
|
|
await autoMigratePolicyStateIfNeeded(root);
|
|
await upsertPolicyRule({ id: 'allow-model', effect: 'allow', actions: ['model.call'] }, root);
|
|
await setPolicyBudget({
|
|
id: 'model-window',
|
|
principal: 'agent:*',
|
|
action: 'model.call',
|
|
maxCostUsd: 1,
|
|
periodMs: 60_000,
|
|
}, root);
|
|
await setPolicyMode('enforce', root);
|
|
|
|
const decisions = await Promise.all(Array.from({ length: 10 }, (_, index) => evaluatePolicyRequest({
|
|
identity: { id: `agent:${index}`, type: 'agent' },
|
|
action: { type: 'model.call', resource: 'openrouter', costUsd: 0.2 },
|
|
}, root)));
|
|
|
|
expect(decisions.filter((decision) => decision.enforcedOutcome === 'allowed')).toHaveLength(5);
|
|
expect(decisions.filter((decision) => decision.enforcedOutcome === 'denied')).toHaveLength(5);
|
|
expect(loadPolicyState(root).usage[0]?.costUsd).toBeCloseTo(1);
|
|
expect(await verifyPolicyLedger(root)).toEqual({ valid: true, length: 10 });
|
|
});
|
|
});
|