claude-flow 3.41.4 → 3.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.proven-config-version +1 -0
- package/.claude/proven-config.json +42 -0
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts +4 -0
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts.map +1 -1
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js +38 -1
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js.map +1 -1
- package/node_modules/@claude-flow/codex/package.json +2 -1
- package/node_modules/@claude-flow/security/dist/index.d.ts +1 -1
- package/node_modules/@claude-flow/security/dist/index.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/index.js +1 -1
- package/node_modules/@claude-flow/security/dist/index.js.map +1 -1
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts +11 -0
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js +0 -0
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js.map +1 -1
- package/node_modules/@claude-flow/security/package.json +1 -0
- package/package.json +1 -1
- package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
- package/v3/@claude-flow/cli/dist/src/commands/doctor.d.ts +7 -0
- package/v3/@claude-flow/cli/dist/src/commands/doctor.js +58 -35
- package/v3/@claude-flow/cli/dist/src/commands/hive-mind.js +4 -3
- package/v3/@claude-flow/cli/dist/src/commands/swarm.js +89 -18
- package/v3/@claude-flow/cli/dist/src/init/claudemd-generator.js +2 -2
- package/v3/@claude-flow/cli/dist/src/mcp-server.js +12 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentbbs-tools.d.ts +15 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentbbs-tools.js +98 -14
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentdb-tools.js +17 -1
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.d.ts +8 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.js +80 -2
- package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +102 -31
- package/v3/@claude-flow/cli/dist/src/mcp-tools/policy-enforcer.d.ts +126 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/policy-enforcer.js +177 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/seraphina-tools.js +7 -2
- package/v3/@claude-flow/cli/dist/src/mcp-tools/x-federation-tools.d.ts +12 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/x-federation-tools.js +87 -5
- package/v3/@claude-flow/cli/dist/src/memory/intelligence.d.ts +14 -2
- package/v3/@claude-flow/cli/dist/src/memory/intelligence.js +33 -12
- package/v3/@claude-flow/cli/dist/src/memory/memory-bridge.js +10 -2
- package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +19 -2
- package/v3/@claude-flow/cli/dist/src/ruvector/graph-backend.js +112 -26
- package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +63 -5
- package/v3/@claude-flow/cli/package.json +1 -1
- package/v3/@claude-flow/cli/dist/src/ruvector/diskann-backend.d.ts +0 -78
- package/v3/@claude-flow/cli/dist/src/ruvector/diskann-backend.js +0 -310
|
@@ -238,6 +238,58 @@ async function describeBackend() {
|
|
|
238
238
|
return 'sqlite';
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
+
/** #3311: one page per round trip, so a store larger than one page is
|
|
242
|
+
* counted rather than silently cut off at the old hardcoded 100000. */
|
|
243
|
+
const MEMORY_STATS_PAGE = 10000;
|
|
244
|
+
/** #3311: a hard stop so a `total` that never agrees with the rows returned
|
|
245
|
+
* cannot spin forever. Reaching it is reported as a truncated count, not as
|
|
246
|
+
* a complete one. */
|
|
247
|
+
const MEMORY_STATS_MAX_PAGES = 200;
|
|
248
|
+
/**
|
|
249
|
+
* Page through the store and return every row, or the first failure.
|
|
250
|
+
*
|
|
251
|
+
* The old call asked for `limit: 100000` in one shot and read only
|
|
252
|
+
* `entries`/`total` off the result — so a bigger store had its namespace
|
|
253
|
+
* breakdown and embedding coverage computed from a prefix, and a listing
|
|
254
|
+
* that reported `success: false` was read as an empty one.
|
|
255
|
+
*/
|
|
256
|
+
async function collectAllEntries(listEntries) {
|
|
257
|
+
const entries = [];
|
|
258
|
+
let total = 0;
|
|
259
|
+
for (let page = 0; page < MEMORY_STATS_MAX_PAGES; page++) {
|
|
260
|
+
const result = await listEntries({ limit: MEMORY_STATS_PAGE, offset: entries.length });
|
|
261
|
+
if (!result.success) {
|
|
262
|
+
return { success: false, entries, total: result.total ?? total, error: result.error };
|
|
263
|
+
}
|
|
264
|
+
total = result.total;
|
|
265
|
+
entries.push(...result.entries);
|
|
266
|
+
if (result.entries.length === 0 || entries.length >= total)
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
return { success: true, entries, total };
|
|
270
|
+
}
|
|
271
|
+
/** #3311: unavailable is its own answer. The zeros this replaces were
|
|
272
|
+
* indistinguishable from a store that really is empty. */
|
|
273
|
+
function memoryStatsUnavailable(error) {
|
|
274
|
+
return { initialized: null, available: false, error };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Version and feature labels from the initialization probe.
|
|
278
|
+
*
|
|
279
|
+
* Metadata only. The probe reads a whole-image snapshot that cannot see live
|
|
280
|
+
* WAL frames, so it is allowed to fail without that meaning the store is
|
|
281
|
+
* missing — which is exactly the conflation #3311 reports.
|
|
282
|
+
*/
|
|
283
|
+
async function readMemoryStatusLabels() {
|
|
284
|
+
try {
|
|
285
|
+
const { checkMemoryInitialization } = await getMemoryFunctions();
|
|
286
|
+
const status = await checkMemoryInitialization();
|
|
287
|
+
return { version: status.version, features: status.features };
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
241
293
|
/**
|
|
242
294
|
* Ensure memory database is initialized and migrate legacy data if needed.
|
|
243
295
|
* #1606: Wrapped in try/catch to prevent process-level crashes that kill
|
|
@@ -704,41 +756,60 @@ export const memoryTools = [
|
|
|
704
756
|
},
|
|
705
757
|
handler: async () => {
|
|
706
758
|
await ensureInitialized();
|
|
707
|
-
const {
|
|
759
|
+
const { listEntries } = await getMemoryFunctions();
|
|
760
|
+
// #3311: the store's own listing decides whether memory is there.
|
|
761
|
+
// `checkMemoryInitialization` opens a whole-image sql.js snapshot of
|
|
762
|
+
// the main database file, which cannot include live SQLite WAL frames
|
|
763
|
+
// — so a store the bridge reads and searches perfectly well came back
|
|
764
|
+
// `initialized: false` from this tool alone. The probe is still read
|
|
765
|
+
// below, for the version and feature labels it is the only source of,
|
|
766
|
+
// but it no longer gets to overrule a working store.
|
|
767
|
+
let listing;
|
|
708
768
|
try {
|
|
709
|
-
|
|
710
|
-
const allEntries = await listEntries({ limit: 100000 });
|
|
711
|
-
// Count by namespace
|
|
712
|
-
const namespaces = {};
|
|
713
|
-
let withEmbeddings = 0;
|
|
714
|
-
for (const entry of allEntries.entries) {
|
|
715
|
-
namespaces[entry.namespace] = (namespaces[entry.namespace] || 0) + 1;
|
|
716
|
-
if (entry.hasEmbedding)
|
|
717
|
-
withEmbeddings++;
|
|
718
|
-
}
|
|
719
|
-
return {
|
|
720
|
-
initialized: status.initialized,
|
|
721
|
-
totalEntries: allEntries.total,
|
|
722
|
-
entriesWithEmbeddings: withEmbeddings,
|
|
723
|
-
embeddingCoverage: allEntries.total > 0
|
|
724
|
-
? `${((withEmbeddings / allEntries.total) * 100).toFixed(1)}%`
|
|
725
|
-
: '0%',
|
|
726
|
-
namespaces,
|
|
727
|
-
backend: await describeBackend(),
|
|
728
|
-
version: status.version || '3.0.0',
|
|
729
|
-
features: status.features || {
|
|
730
|
-
vectorEmbeddings: true,
|
|
731
|
-
hnswIndex: true,
|
|
732
|
-
semanticSearch: true,
|
|
733
|
-
},
|
|
734
|
-
};
|
|
769
|
+
listing = await collectAllEntries(listEntries);
|
|
735
770
|
}
|
|
736
771
|
catch (error) {
|
|
737
|
-
return
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
772
|
+
return memoryStatsUnavailable(error instanceof Error ? error.message : 'Unknown error');
|
|
773
|
+
}
|
|
774
|
+
if (!listing.success) {
|
|
775
|
+
// A failed query is not an empty store. Reporting zeros here is
|
|
776
|
+
// what made a WAL refusal look like "you have no memories".
|
|
777
|
+
return memoryStatsUnavailable(listing.error || 'listEntries reported failure');
|
|
741
778
|
}
|
|
779
|
+
// Object.create(null): a namespace literally named `__proto__` is a
|
|
780
|
+
// legal key, and assigning it on an object literal sets the prototype
|
|
781
|
+
// instead of counting anything — so that namespace's entries vanished
|
|
782
|
+
// from the breakdown while still being counted in the total.
|
|
783
|
+
const namespaces = Object.create(null);
|
|
784
|
+
let withEmbeddings = 0;
|
|
785
|
+
for (const entry of listing.entries) {
|
|
786
|
+
namespaces[entry.namespace] = (namespaces[entry.namespace] || 0) + 1;
|
|
787
|
+
if (entry.hasEmbedding)
|
|
788
|
+
withEmbeddings++;
|
|
789
|
+
}
|
|
790
|
+
const counted = listing.entries.length;
|
|
791
|
+
const status = await readMemoryStatusLabels();
|
|
792
|
+
return {
|
|
793
|
+
initialized: true,
|
|
794
|
+
totalEntries: listing.total,
|
|
795
|
+
entriesCounted: counted,
|
|
796
|
+
// The breakdown below covers `entriesCounted` rows, which is every
|
|
797
|
+
// row unless the listing was truncated; say so rather than letting
|
|
798
|
+
// a partial count read as the whole store.
|
|
799
|
+
...(counted < listing.total ? { truncated: true } : {}),
|
|
800
|
+
entriesWithEmbeddings: withEmbeddings,
|
|
801
|
+
embeddingCoverage: counted > 0
|
|
802
|
+
? `${((withEmbeddings / counted) * 100).toFixed(1)}%`
|
|
803
|
+
: '0%',
|
|
804
|
+
namespaces: { ...namespaces },
|
|
805
|
+
backend: await describeBackend(),
|
|
806
|
+
version: status.version || '3.0.0',
|
|
807
|
+
features: status.features || {
|
|
808
|
+
vectorEmbeddings: true,
|
|
809
|
+
hnswIndex: true,
|
|
810
|
+
semanticSearch: true,
|
|
811
|
+
},
|
|
812
|
+
};
|
|
742
813
|
},
|
|
743
814
|
},
|
|
744
815
|
{
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Governance Policy Enforcer (opt-in).
|
|
3
|
+
*
|
|
4
|
+
* `.harness/mcp-policy.json` declares governance intent (defaultDeny,
|
|
5
|
+
* auditLog, maxToolCallsPerTurn, dangerousPatterns, ...) for the claude-flow
|
|
6
|
+
* MCP server, but until now nothing in the running server (mcp-server.ts)
|
|
7
|
+
* ever read it: `harness mcp-scan` grades the file's *posture* offline, the
|
|
8
|
+
* live `tools/call` dispatch never consulted it. Any connected MCP client
|
|
9
|
+
* could call every registered tool with no audit trail and no call budget.
|
|
10
|
+
*
|
|
11
|
+
* This module wires the two policy fields that are actually in this
|
|
12
|
+
* server's jurisdiction, per the policy file's own rationale comment
|
|
13
|
+
* (`dangerousPatterns` / `allowShell` / `allowNetwork` / `allowFileWrite`
|
|
14
|
+
* describe the native-Claude-Code-tool layer — Bash/Write/Edit/WebFetch —
|
|
15
|
+
* not this MCP server's memory_-, hooks_-, agentdb_-prefixed tool surface,
|
|
16
|
+
* so they are intentionally left unenforced here):
|
|
17
|
+
* - `auditLog`: append a JSONL record for every `tools/call`.
|
|
18
|
+
* - `maxToolCallsPerTurn`: bound calls per MCP *session* (one stdio
|
|
19
|
+
* process lifetime), deny once exceeded.
|
|
20
|
+
*
|
|
21
|
+
* Fully opt-in via `RUFLO_MCP_ENFORCE_POLICY=1` (or `true`). Unset/false
|
|
22
|
+
* means every function below is a no-op on the hot path — the pre-existing
|
|
23
|
+
* `tools/call` behavior is unchanged.
|
|
24
|
+
*
|
|
25
|
+
* FAIL-CLOSED once enforcement is enabled (PR #3139 review round 1):
|
|
26
|
+
* a missing/malformed policy file, or a failed mandatory audit-log write,
|
|
27
|
+
* denies the call rather than silently degrading to unrestricted execution.
|
|
28
|
+
* The whole point of opting in is a restriction that actually holds; an
|
|
29
|
+
* enforcement flag that quietly falls back to "no restriction" on its own
|
|
30
|
+
* misconfiguration defeats the feature. See `evaluateToolCall()`.
|
|
31
|
+
*
|
|
32
|
+
* Known scope limits (disclosed, not fixed here):
|
|
33
|
+
* - Only wired into the stdio `tools/call` dispatch
|
|
34
|
+
* (`MCPServerManager.handleMCPMessage`). The separate HTTP/websocket
|
|
35
|
+
* path (`startHttpServer()`, via `@claude-flow/mcp`) does not call
|
|
36
|
+
* this module and is unaffected even when this flag is set.
|
|
37
|
+
*
|
|
38
|
+
* `maxToolCallsPerTurn` reset semantics (dream-cycle 2026-09-01, follow-up
|
|
39
|
+
* to 2026-08-31 review round 1): despite the field's name, the original
|
|
40
|
+
* implementation enforced a *session-lifetime cumulative* cap that never
|
|
41
|
+
* reset — a long-lived stdio session could exhaust the budget under
|
|
42
|
+
* entirely legitimate use and stay locked out until the MCP server process
|
|
43
|
+
* restarted. Research that night (see the dream-cycle gist) found: (1) the
|
|
44
|
+
* MCP spec only mandates "rate limit tool invocations" with zero mechanism
|
|
45
|
+
* guidance, and its 2026-07-28 revision (SEP-2567) is actively removing the
|
|
46
|
+
* session concept from the protocol entirely; (2) every framework/product
|
|
47
|
+
* that gets this right (FastMCP's rate-limiting middleware, the PolicyLayer
|
|
48
|
+
* MCP firewall, Cloudflare's public rate limiter) anchors the reset to
|
|
49
|
+
* wall-clock time, not to a turn or session counter that never decays —
|
|
50
|
+
* a turn-count reset is gameable by a chatty loop re-arming its own budget,
|
|
51
|
+
* which wall-clock time is not. This module now enforces a *sliding
|
|
52
|
+
* wall-clock window*: `maxToolCallsPerTurn` calls are allowed per rolling
|
|
53
|
+
* `turnWindowMs` (default 60000) per session, keyed by call timestamp so
|
|
54
|
+
* calls fall out of the window as time passes rather than accumulating
|
|
55
|
+
* forever. `now` is an injectable parameter (defaults to `Date.now`) so
|
|
56
|
+
* production callers need no change and tests stay fully deterministic via
|
|
57
|
+
* `vi.useFakeTimers()`.
|
|
58
|
+
*/
|
|
59
|
+
export interface McpPolicy {
|
|
60
|
+
schema?: number;
|
|
61
|
+
policyVersion?: number;
|
|
62
|
+
harnessId?: string;
|
|
63
|
+
defaultDeny?: boolean;
|
|
64
|
+
auditLog?: boolean;
|
|
65
|
+
requireApprovalForDangerous?: boolean;
|
|
66
|
+
toolTimeoutMs?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Despite the name, this is a WALL-CLOCK rate limit, not a literal
|
|
69
|
+
* conversational-turn counter — MCP has no protocol-level concept of a
|
|
70
|
+
* "turn" to count against (confirmed: the spec is silent on it, and its
|
|
71
|
+
* 2026-07-28 revision removes the session concept entirely). Enforced as
|
|
72
|
+
* "at most this many calls in any rolling `turnWindowMs` window" per
|
|
73
|
+
* session. Treat it, and document it to callers, as rate limiting.
|
|
74
|
+
*/
|
|
75
|
+
maxToolCallsPerTurn?: number;
|
|
76
|
+
/** Rolling window (ms) over which `maxToolCallsPerTurn` is counted. Default 60000. */
|
|
77
|
+
turnWindowMs?: number;
|
|
78
|
+
dangerousPatterns?: string[];
|
|
79
|
+
approvedServers?: string[];
|
|
80
|
+
[key: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
export declare function isPolicyEnforcementEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
83
|
+
export declare function loadMcpPolicy(policyPath?: string): McpPolicy | null;
|
|
84
|
+
/** Test-only: clear per-session call state between test cases. */
|
|
85
|
+
export declare function resetPolicyEnforcerState(): void;
|
|
86
|
+
export interface PolicyCheckResult {
|
|
87
|
+
allowed: boolean;
|
|
88
|
+
reason?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Checks (and, if allowed, records) a tool call against
|
|
92
|
+
* `policy.maxToolCallsPerTurn`, counted over a sliding window of
|
|
93
|
+
* `policy.turnWindowMs` (default 60000ms) rather than the session's whole
|
|
94
|
+
* lifetime. Calls older than the window are pruned before comparing count
|
|
95
|
+
* to limit, so a session that pauses gets its budget back rather than
|
|
96
|
+
* staying denied until the process restarts. `now` defaults to `Date.now`
|
|
97
|
+
* for production callers; tests inject a controlled clock instead.
|
|
98
|
+
*/
|
|
99
|
+
export declare function checkAndRecordCall(policy: McpPolicy, sessionId: string, now?: number): PolicyCheckResult;
|
|
100
|
+
export interface AuditLogEntry {
|
|
101
|
+
timestamp: string;
|
|
102
|
+
sessionId: string;
|
|
103
|
+
toolName: string;
|
|
104
|
+
allowed: boolean;
|
|
105
|
+
reason?: string;
|
|
106
|
+
}
|
|
107
|
+
/** Test-only: redirect the audit log to a temp file instead of the default path. */
|
|
108
|
+
export declare function setAuditLogPathForTesting(p: string | null): void;
|
|
109
|
+
export declare function getAuditLogPath(): string;
|
|
110
|
+
/**
|
|
111
|
+
* Appends one JSONL audit record. Returns `true` if `policy.auditLog` is not
|
|
112
|
+
* set (nothing was required) or the write succeeded; `false` only when
|
|
113
|
+
* `auditLog` is required and the write itself failed (disk full, unwritable
|
|
114
|
+
* path, etc). Never throws — the caller (`evaluateToolCall`) decides what a
|
|
115
|
+
* failed *mandatory* write means for the call (fail-closed: deny it).
|
|
116
|
+
*/
|
|
117
|
+
export declare function appendAuditLog(policy: McpPolicy, entry: AuditLogEntry): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Single enforcement entry point for a `tools/call` dispatch. Combines, in
|
|
120
|
+
* order: fail-closed on a missing/malformed policy, the per-session call
|
|
121
|
+
* budget, and fail-closed on a failed mandatory audit-log write. `policy`
|
|
122
|
+
* is the result of `loadMcpPolicy()` — pass `null` straight through when it
|
|
123
|
+
* failed to load, rather than re-deciding that here.
|
|
124
|
+
*/
|
|
125
|
+
export declare function evaluateToolCall(policy: McpPolicy | null, sessionId: string, toolName: string, now?: number): PolicyCheckResult;
|
|
126
|
+
//# sourceMappingURL=policy-enforcer.d.ts.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Governance Policy Enforcer (opt-in).
|
|
3
|
+
*
|
|
4
|
+
* `.harness/mcp-policy.json` declares governance intent (defaultDeny,
|
|
5
|
+
* auditLog, maxToolCallsPerTurn, dangerousPatterns, ...) for the claude-flow
|
|
6
|
+
* MCP server, but until now nothing in the running server (mcp-server.ts)
|
|
7
|
+
* ever read it: `harness mcp-scan` grades the file's *posture* offline, the
|
|
8
|
+
* live `tools/call` dispatch never consulted it. Any connected MCP client
|
|
9
|
+
* could call every registered tool with no audit trail and no call budget.
|
|
10
|
+
*
|
|
11
|
+
* This module wires the two policy fields that are actually in this
|
|
12
|
+
* server's jurisdiction, per the policy file's own rationale comment
|
|
13
|
+
* (`dangerousPatterns` / `allowShell` / `allowNetwork` / `allowFileWrite`
|
|
14
|
+
* describe the native-Claude-Code-tool layer — Bash/Write/Edit/WebFetch —
|
|
15
|
+
* not this MCP server's memory_-, hooks_-, agentdb_-prefixed tool surface,
|
|
16
|
+
* so they are intentionally left unenforced here):
|
|
17
|
+
* - `auditLog`: append a JSONL record for every `tools/call`.
|
|
18
|
+
* - `maxToolCallsPerTurn`: bound calls per MCP *session* (one stdio
|
|
19
|
+
* process lifetime), deny once exceeded.
|
|
20
|
+
*
|
|
21
|
+
* Fully opt-in via `RUFLO_MCP_ENFORCE_POLICY=1` (or `true`). Unset/false
|
|
22
|
+
* means every function below is a no-op on the hot path — the pre-existing
|
|
23
|
+
* `tools/call` behavior is unchanged.
|
|
24
|
+
*
|
|
25
|
+
* FAIL-CLOSED once enforcement is enabled (PR #3139 review round 1):
|
|
26
|
+
* a missing/malformed policy file, or a failed mandatory audit-log write,
|
|
27
|
+
* denies the call rather than silently degrading to unrestricted execution.
|
|
28
|
+
* The whole point of opting in is a restriction that actually holds; an
|
|
29
|
+
* enforcement flag that quietly falls back to "no restriction" on its own
|
|
30
|
+
* misconfiguration defeats the feature. See `evaluateToolCall()`.
|
|
31
|
+
*
|
|
32
|
+
* Known scope limits (disclosed, not fixed here):
|
|
33
|
+
* - Only wired into the stdio `tools/call` dispatch
|
|
34
|
+
* (`MCPServerManager.handleMCPMessage`). The separate HTTP/websocket
|
|
35
|
+
* path (`startHttpServer()`, via `@claude-flow/mcp`) does not call
|
|
36
|
+
* this module and is unaffected even when this flag is set.
|
|
37
|
+
*
|
|
38
|
+
* `maxToolCallsPerTurn` reset semantics (dream-cycle 2026-09-01, follow-up
|
|
39
|
+
* to 2026-08-31 review round 1): despite the field's name, the original
|
|
40
|
+
* implementation enforced a *session-lifetime cumulative* cap that never
|
|
41
|
+
* reset — a long-lived stdio session could exhaust the budget under
|
|
42
|
+
* entirely legitimate use and stay locked out until the MCP server process
|
|
43
|
+
* restarted. Research that night (see the dream-cycle gist) found: (1) the
|
|
44
|
+
* MCP spec only mandates "rate limit tool invocations" with zero mechanism
|
|
45
|
+
* guidance, and its 2026-07-28 revision (SEP-2567) is actively removing the
|
|
46
|
+
* session concept from the protocol entirely; (2) every framework/product
|
|
47
|
+
* that gets this right (FastMCP's rate-limiting middleware, the PolicyLayer
|
|
48
|
+
* MCP firewall, Cloudflare's public rate limiter) anchors the reset to
|
|
49
|
+
* wall-clock time, not to a turn or session counter that never decays —
|
|
50
|
+
* a turn-count reset is gameable by a chatty loop re-arming its own budget,
|
|
51
|
+
* which wall-clock time is not. This module now enforces a *sliding
|
|
52
|
+
* wall-clock window*: `maxToolCallsPerTurn` calls are allowed per rolling
|
|
53
|
+
* `turnWindowMs` (default 60000) per session, keyed by call timestamp so
|
|
54
|
+
* calls fall out of the window as time passes rather than accumulating
|
|
55
|
+
* forever. `now` is an injectable parameter (defaults to `Date.now`) so
|
|
56
|
+
* production callers need no change and tests stay fully deterministic via
|
|
57
|
+
* `vi.useFakeTimers()`.
|
|
58
|
+
*/
|
|
59
|
+
import * as fs from 'fs';
|
|
60
|
+
import * as path from 'path';
|
|
61
|
+
import * as os from 'os';
|
|
62
|
+
export function isPolicyEnforcementEnabled(env = process.env) {
|
|
63
|
+
const v = env.RUFLO_MCP_ENFORCE_POLICY;
|
|
64
|
+
return v === '1' || (v ?? '').toLowerCase() === 'true';
|
|
65
|
+
}
|
|
66
|
+
export function loadMcpPolicy(policyPath = path.join(process.cwd(), '.harness', 'mcp-policy.json')) {
|
|
67
|
+
try {
|
|
68
|
+
const raw = fs.readFileSync(policyPath, 'utf-8');
|
|
69
|
+
const parsed = JSON.parse(raw);
|
|
70
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
71
|
+
return null;
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const DEFAULT_TURN_WINDOW_MS = 60_000;
|
|
79
|
+
const sessionState = new Map();
|
|
80
|
+
/** Test-only: clear per-session call state between test cases. */
|
|
81
|
+
export function resetPolicyEnforcerState() {
|
|
82
|
+
sessionState.clear();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Checks (and, if allowed, records) a tool call against
|
|
86
|
+
* `policy.maxToolCallsPerTurn`, counted over a sliding window of
|
|
87
|
+
* `policy.turnWindowMs` (default 60000ms) rather than the session's whole
|
|
88
|
+
* lifetime. Calls older than the window are pruned before comparing count
|
|
89
|
+
* to limit, so a session that pauses gets its budget back rather than
|
|
90
|
+
* staying denied until the process restarts. `now` defaults to `Date.now`
|
|
91
|
+
* for production callers; tests inject a controlled clock instead.
|
|
92
|
+
*/
|
|
93
|
+
export function checkAndRecordCall(policy, sessionId, now = Date.now()) {
|
|
94
|
+
const limit = policy.maxToolCallsPerTurn;
|
|
95
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) {
|
|
96
|
+
return { allowed: true };
|
|
97
|
+
}
|
|
98
|
+
const configuredWindow = policy.turnWindowMs;
|
|
99
|
+
const windowMs = typeof configuredWindow === 'number' && Number.isFinite(configuredWindow) && configuredWindow > 0
|
|
100
|
+
? configuredWindow
|
|
101
|
+
: DEFAULT_TURN_WINDOW_MS;
|
|
102
|
+
const state = sessionState.get(sessionId) ?? { callTimes: [] };
|
|
103
|
+
const cutoff = now - windowMs;
|
|
104
|
+
state.callTimes = state.callTimes.filter((t) => t > cutoff);
|
|
105
|
+
if (state.callTimes.length >= limit) {
|
|
106
|
+
sessionState.set(sessionId, state);
|
|
107
|
+
return {
|
|
108
|
+
allowed: false,
|
|
109
|
+
reason: `maxToolCallsPerTurn (${limit}) exceeded within the last ${windowMs}ms for this session`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
state.callTimes.push(now);
|
|
113
|
+
sessionState.set(sessionId, state);
|
|
114
|
+
return { allowed: true };
|
|
115
|
+
}
|
|
116
|
+
let auditLogPathOverride = null;
|
|
117
|
+
/** Test-only: redirect the audit log to a temp file instead of the default path. */
|
|
118
|
+
export function setAuditLogPathForTesting(p) {
|
|
119
|
+
auditLogPathOverride = p;
|
|
120
|
+
}
|
|
121
|
+
function defaultAuditLogPath() {
|
|
122
|
+
return path.join(os.tmpdir(), 'ruflo-mcp-audit.jsonl');
|
|
123
|
+
}
|
|
124
|
+
export function getAuditLogPath() {
|
|
125
|
+
return auditLogPathOverride ?? defaultAuditLogPath();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Appends one JSONL audit record. Returns `true` if `policy.auditLog` is not
|
|
129
|
+
* set (nothing was required) or the write succeeded; `false` only when
|
|
130
|
+
* `auditLog` is required and the write itself failed (disk full, unwritable
|
|
131
|
+
* path, etc). Never throws — the caller (`evaluateToolCall`) decides what a
|
|
132
|
+
* failed *mandatory* write means for the call (fail-closed: deny it).
|
|
133
|
+
*/
|
|
134
|
+
export function appendAuditLog(policy, entry) {
|
|
135
|
+
if (!policy.auditLog)
|
|
136
|
+
return true;
|
|
137
|
+
try {
|
|
138
|
+
fs.appendFileSync(getAuditLogPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Single enforcement entry point for a `tools/call` dispatch. Combines, in
|
|
147
|
+
* order: fail-closed on a missing/malformed policy, the per-session call
|
|
148
|
+
* budget, and fail-closed on a failed mandatory audit-log write. `policy`
|
|
149
|
+
* is the result of `loadMcpPolicy()` — pass `null` straight through when it
|
|
150
|
+
* failed to load, rather than re-deciding that here.
|
|
151
|
+
*/
|
|
152
|
+
export function evaluateToolCall(policy, sessionId, toolName, now = Date.now()) {
|
|
153
|
+
if (policy === null) {
|
|
154
|
+
return {
|
|
155
|
+
allowed: false,
|
|
156
|
+
reason: 'RUFLO_MCP_ENFORCE_POLICY is set but .harness/mcp-policy.json is missing or invalid — failing closed',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const budget = checkAndRecordCall(policy, sessionId, now);
|
|
160
|
+
const auditOk = appendAuditLog(policy, {
|
|
161
|
+
timestamp: new Date(now).toISOString(),
|
|
162
|
+
sessionId,
|
|
163
|
+
toolName,
|
|
164
|
+
allowed: budget.allowed,
|
|
165
|
+
reason: budget.reason,
|
|
166
|
+
});
|
|
167
|
+
if (!budget.allowed)
|
|
168
|
+
return budget;
|
|
169
|
+
if (!auditOk) {
|
|
170
|
+
return {
|
|
171
|
+
allowed: false,
|
|
172
|
+
reason: 'audit log write failed and policy.auditLog is required — failing closed',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { allowed: true };
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=policy-enforcer.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { relayPayload } from './x-federation-tools.js';
|
|
1
2
|
// ADR-125 precedence: explicit tool args (metaLlmUrl / gatewayUrl) take precedence over the
|
|
2
3
|
// SERAPHINA_METALLM_URL / RUFLO_X_GATEWAY_URL env vars, which precede the defaults.
|
|
3
4
|
const META_LLM = (override) => (override || process.env.SERAPHINA_METALLM_URL || 'https://api.cognitum.one').replace(/\/$/, '');
|
|
@@ -14,7 +15,9 @@ async function gatewayRead(uri, gatewayUrl) {
|
|
|
14
15
|
const text = await res.text();
|
|
15
16
|
const line = text.split('\n').find((l) => l.startsWith('data:'));
|
|
16
17
|
const p = JSON.parse(line ? line.slice(5) : text);
|
|
17
|
-
|
|
18
|
+
// roster and claims are relay-sourced and therefore fenced (#3300). These values
|
|
19
|
+
// are indexed directly below, so take the payload, not the envelope.
|
|
20
|
+
return relayPayload(p.result?.contents?.[0]?.text ?? '{}');
|
|
18
21
|
}
|
|
19
22
|
async function gatewaySync(sinceSeconds, limit, gatewayUrl) {
|
|
20
23
|
const res = await fetch(`${GATEWAY(gatewayUrl)}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
|
@@ -22,7 +25,9 @@ async function gatewaySync(sinceSeconds, limit, gatewayUrl) {
|
|
|
22
25
|
const text = await res.text();
|
|
23
26
|
const line = text.split('\n').find((l) => l.startsWith('data:'));
|
|
24
27
|
const p = JSON.parse(line ? line.slice(5) : text);
|
|
25
|
-
|
|
28
|
+
// federation_sync is relay-sourced and therefore fenced (#3300); `.messages` is
|
|
29
|
+
// read directly below, so an envelope here would silently mean "empty swarm".
|
|
30
|
+
return relayPayload(p.result?.content?.[0]?.text ?? '{}');
|
|
26
31
|
}
|
|
27
32
|
export async function askSeraphina(goal, opts = {}) {
|
|
28
33
|
// Credential: intentionally env-only (never a CLI flag). Registered in audit-env-var-precedence.mjs.
|
|
@@ -8,5 +8,17 @@
|
|
|
8
8
|
* `ruv://federation/registry` resource), not through these gateway-identity tools.
|
|
9
9
|
*/
|
|
10
10
|
import type { MCPTool } from './types.js';
|
|
11
|
+
export declare function parseGatewayText(text: string): Record<string, unknown>;
|
|
12
|
+
/**
|
|
13
|
+
* The payload, for consumers INSIDE this package that immediately index the
|
|
14
|
+
* value (`recent.messages`, `Object.keys(roster)`).
|
|
15
|
+
*
|
|
16
|
+
* At the MCP boundary we return the whole envelope so the caller can see whose
|
|
17
|
+
* words these are. Internally that shape is a hazard: reading `.messages` off an
|
|
18
|
+
* envelope yields undefined and `Object.keys()` yields the envelope's own five
|
|
19
|
+
* keys, so a miscount looks like a real answer. Never do a bare property read on
|
|
20
|
+
* a parseGatewayText result — come through here.
|
|
21
|
+
*/
|
|
22
|
+
export declare function relayPayload(text: string): Record<string, unknown>;
|
|
11
23
|
export declare const xFederationTools: MCPTool[];
|
|
12
24
|
//# sourceMappingURL=x-federation-tools.d.ts.map
|
|
@@ -18,18 +18,100 @@ async function gatewayRpc(method, params, gatewayUrl) {
|
|
|
18
18
|
throw new Error(`x.ruv.io: ${payload.error.message ?? 'rpc error'}`);
|
|
19
19
|
return payload.result;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Relay-sourced gateway responses are not bare JSON. Since #3300 the gateway
|
|
23
|
+
* wraps anything published by other federation members in a provenance envelope
|
|
24
|
+
* (plugins/ruflo-x-gateway/src/untrusted.mjs): several lines of gateway-authored
|
|
25
|
+
* prose, then the JSON body between a matched
|
|
26
|
+
* `<<<UNTRUSTED_RELAY_DATA <uuid>>>>` / `<<<END_UNTRUSTED_RELAY_DATA <uuid>>>>`
|
|
27
|
+
* pair. `JSON.parse` on the whole string fails on the first prose word, which is
|
|
28
|
+
* the `Unexpected token 'T', "The block "...` seen from every federation read.
|
|
29
|
+
*
|
|
30
|
+
* Three things keep a publisher from closing the block early, and it is worth
|
|
31
|
+
* being precise about which one is doing the work today:
|
|
32
|
+
* 1. The body is JSON.stringify'd, so it is a SINGLE line — a publisher's text
|
|
33
|
+
* cannot contain a raw newline, and the markers below are newline-anchored.
|
|
34
|
+
* This is what actually neutralises forged markers in relay content today.
|
|
35
|
+
* 2. The token backreference: a forged END carrying any other token does not
|
|
36
|
+
* terminate the region. This is the defence that survives (1) — if the body
|
|
37
|
+
* is ever pretty-printed, it becomes the only one left. It is tested
|
|
38
|
+
* directly rather than incidentally, so it cannot be refactored away quietly.
|
|
39
|
+
* 3. Exactly one opening marker is permitted. `fenceUntrusted` splices its
|
|
40
|
+
* `note` verbatim BEFORE the fence, so a caller that ever interpolates
|
|
41
|
+
* relay-derived text into a note could otherwise smuggle in a complete
|
|
42
|
+
* earlier envelope; first-match-wins would return it, with untrusted:false.
|
|
43
|
+
*
|
|
44
|
+
* We deliberately return the WHOLE envelope (`untrusted`, `provenance`, `relay`,
|
|
45
|
+
* `retrievedAt`, `data`) rather than lifting `data` out of it. The point of the
|
|
46
|
+
* envelope is that a caller can tell whose words these are; quietly unwrapping to
|
|
47
|
+
* the payload would restore valid JSON by discarding the labelling that made it
|
|
48
|
+
* safe to read. Unfenced responses (the registry resource, gateway-authored
|
|
49
|
+
* errors) parse unchanged.
|
|
50
|
+
*/
|
|
51
|
+
const UNTRUSTED_FENCE = /<<<UNTRUSTED_RELAY_DATA ([0-9a-fA-F-]{36})>>>\n([\s\S]*?)\n<<<END_UNTRUSTED_RELAY_DATA \1>>>/;
|
|
52
|
+
// Count only NEWLINE-ANCHORED opening markers. A marker inside the body is just
|
|
53
|
+
// characters — the body is one JSON line, so it can never be preceded by a raw
|
|
54
|
+
// newline and can never open a fence. Counting raw occurrences instead would make
|
|
55
|
+
// a publisher able to hard-fail every read simply by typing the marker into a
|
|
56
|
+
// message, which trades a parse bug for a denial of service.
|
|
57
|
+
const OPEN_MARKER_ANCHORED = /(?:^|\n)<<<UNTRUSTED_RELAY_DATA /g;
|
|
58
|
+
export function parseGatewayText(text) {
|
|
59
|
+
// One response carries exactly one envelope. More than one means something
|
|
60
|
+
// upstream spliced an envelope-shaped string into the response, and picking
|
|
61
|
+
// either is a guess — refuse rather than choose.
|
|
62
|
+
const opens = (text.match(OPEN_MARKER_ANCHORED) ?? []).length;
|
|
63
|
+
if (opens > 1) {
|
|
64
|
+
throw new Error('x.ruv.io: response carries more than one untrusted-data envelope (tampered response)');
|
|
65
|
+
}
|
|
66
|
+
const fenced = UNTRUSTED_FENCE.exec(text);
|
|
67
|
+
if (fenced)
|
|
68
|
+
return JSON.parse(fenced[2]);
|
|
69
|
+
// An opening marker with no matching close is a truncated or tampered response.
|
|
70
|
+
// Fail loudly: parsing the remainder would silently drop relay content.
|
|
71
|
+
if (opens === 1) {
|
|
72
|
+
throw new Error('x.ruv.io: untrusted-data envelope is unterminated (truncated or tampered response)');
|
|
73
|
+
}
|
|
74
|
+
return JSON.parse(text);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The payload, for consumers INSIDE this package that immediately index the
|
|
78
|
+
* value (`recent.messages`, `Object.keys(roster)`).
|
|
79
|
+
*
|
|
80
|
+
* At the MCP boundary we return the whole envelope so the caller can see whose
|
|
81
|
+
* words these are. Internally that shape is a hazard: reading `.messages` off an
|
|
82
|
+
* envelope yields undefined and `Object.keys()` yields the envelope's own five
|
|
83
|
+
* keys, so a miscount looks like a real answer. Never do a bare property read on
|
|
84
|
+
* a parseGatewayText result — come through here.
|
|
85
|
+
*/
|
|
86
|
+
export function relayPayload(text) {
|
|
87
|
+
const parsed = parseGatewayText(text);
|
|
88
|
+
return (parsed.untrusted === true && parsed.data !== undefined
|
|
89
|
+
? parsed.data
|
|
90
|
+
: parsed);
|
|
91
|
+
}
|
|
21
92
|
async function gatewayTool(name, args) {
|
|
22
93
|
const { gatewayUrl, ...rest } = args;
|
|
23
94
|
const r = (await gatewayRpc('tools/call', { name, arguments: rest }, gatewayUrl));
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
95
|
+
const raw = r.content?.[0]?.text ?? '{}';
|
|
96
|
+
// An isError result is the SDK's createToolError, whose message is raw text and
|
|
97
|
+
// not JSON. Parsing first turns "private channels cannot be published…" into
|
|
98
|
+
// "Unexpected token 'p'" — the same class of bug this parser exists to fix.
|
|
99
|
+
if (r.isError) {
|
|
100
|
+
let msg = raw;
|
|
101
|
+
try {
|
|
102
|
+
msg = String(parseGatewayText(raw).error ?? raw);
|
|
103
|
+
}
|
|
104
|
+
catch { /* raw text: use as-is */ }
|
|
105
|
+
throw new Error(msg || 'gateway tool error');
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseGatewayText(raw);
|
|
108
|
+
if (parsed.error)
|
|
109
|
+
throw new Error(String(parsed.error));
|
|
28
110
|
return parsed;
|
|
29
111
|
}
|
|
30
112
|
async function gatewayResource(uri, gatewayUrl) {
|
|
31
113
|
const r = (await gatewayRpc('resources/read', { uri }, gatewayUrl));
|
|
32
|
-
return
|
|
114
|
+
return parseGatewayText(r.contents?.[0]?.text ?? '{}');
|
|
33
115
|
}
|
|
34
116
|
// Credential: intentionally env-only (a secret must never be a CLI flag — it would land in
|
|
35
117
|
// shell history / process lists). Registered in scripts/audit-env-var-precedence.mjs.
|
|
@@ -178,13 +178,25 @@ declare class LocalReasoningBank {
|
|
|
178
178
|
*/
|
|
179
179
|
store(pattern: Omit<StoredPattern, 'usageCount' | 'createdAt' | 'lastUsedAt'> & Partial<StoredPattern>): void;
|
|
180
180
|
/**
|
|
181
|
-
* Find similar patterns by embedding
|
|
181
|
+
* Find similar patterns by embedding.
|
|
182
|
+
*
|
|
183
|
+
* `confidence` on each result is the pattern's own learned reliability
|
|
184
|
+
* (unchanged from storage) — NOT how well it matches this query. The
|
|
185
|
+
* per-query cosine score is returned separately as `similarity`. Callers
|
|
186
|
+
* that want "how good a semantic match is this" must read `.similarity`;
|
|
187
|
+
* callers that want "how reliable has this pattern proven to be" read
|
|
188
|
+
* `.confidence`. Prior to this fix both were conflated (confidence was
|
|
189
|
+
* overwritten with the cosine score), which silently broke any consumer
|
|
190
|
+
* that needed to tell them apart (found during the 2026-09-12 dream-cycle
|
|
191
|
+
* intelligence-surface review).
|
|
182
192
|
*/
|
|
183
193
|
findSimilar(queryEmbedding: number[], options: {
|
|
184
194
|
k?: number;
|
|
185
195
|
threshold?: number;
|
|
186
196
|
type?: string;
|
|
187
|
-
}): StoredPattern
|
|
197
|
+
}): (StoredPattern & {
|
|
198
|
+
similarity: number;
|
|
199
|
+
})[];
|
|
188
200
|
/**
|
|
189
201
|
* Optimized cosine similarity
|
|
190
202
|
*/
|