sparda-mcp 0.5.0 → 0.5.2
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/package.json +6 -3
- package/src/commands/init.js +31 -1
- package/src/generator/express.js +1 -6
- package/src/generator/fastapi.js +5 -7
- package/src/index.js +2 -0
- package/src/probe/express-shim-esm.mjs +27 -0
- package/src/probe/express-shim.cjs +208 -0
- package/src/probe/fastapi-probe.py +146 -0
- package/src/probe/integrate.js +104 -0
- package/src/probe/probe.js +243 -0
- package/src/probe/reconcile.js +160 -0
- package/src/server/condenser.js +3 -2
- package/src/server/confirmation.js +165 -0
- package/src/server/context-carrier.js +322 -0
- package/src/server/crystallize.js +1 -1
- package/src/server/engine.js +579 -0
- package/src/server/idle.js +1 -1
- package/src/server/persistence.js +269 -0
- package/src/server/stdio.js +182 -34
- package/templates/express-router.txt +203 -32
- package/templates/fastapi-router.txt +238 -1
|
@@ -55,6 +55,51 @@ function spardaPuritySnapshot() {
|
|
|
55
55
|
}
|
|
56
56
|
return out;
|
|
57
57
|
}
|
|
58
|
+
// ── horizontal scale (Brief #2): Quorum Sensing × G-Map CRDT ──────────────────
|
|
59
|
+
// N host replicas each learn purity locally; gossip converges the grow-only counts so a
|
|
60
|
+
// route observed-pure on ANY replica becomes pure on ALL — no coordinator, no shared
|
|
61
|
+
// store, no new dependency. "Zero infra" holds: solo (no SPARDA_PEERS) starts no timer and
|
|
62
|
+
// pays nothing. CRDT merge = max() per counter (commutative, associative, idempotent →
|
|
63
|
+
// eventual convergence). value-free: only tool NAMES (already public via /tools) and integer
|
|
64
|
+
// counts cross the wire — never an argument, never a body.
|
|
65
|
+
const SPARDA_PEERS = (process.env.SPARDA_PEERS ?? '').split(',').map((s__ANY_TYPE__) => s.trim().replace(/\/$/, '')).filter((s__ANY_TYPE__) => /^https?:\/\//.test(s));
|
|
66
|
+
const SPARDA_GOSSIP_MS = Math.max(50, Number(process.env.SPARDA_GOSSIP_MS) || 30000);
|
|
67
|
+
const SPARDA_GOSSIP_TIMEOUT_MS = Math.max(250, Number(process.env.SPARDA_GOSSIP_TIMEOUT_MS) || 2000);
|
|
68
|
+
function spardaGossipSnapshot() {
|
|
69
|
+
const out__STATS_TYPE__ = {};
|
|
70
|
+
for (const [name, pu] of Object.entries(SPARDA_PURITY)) out[name] = { repeats: pu.repeats, mismatches: pu.mismatches };
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
// merge a peer's snapshot: max() per counter, ONLY for tools we already know (bounded — the
|
|
74
|
+
// map can never grow past the tool count; an unknown or injected key is silently dropped).
|
|
75
|
+
function spardaMergeGossip(remote__ANY_TYPE__) {
|
|
76
|
+
if (!remote || typeof remote !== 'object' || Array.isArray(remote)) return 0;
|
|
77
|
+
let merged = 0;
|
|
78
|
+
for (const [name, rv] of Object.entries(remote)) {
|
|
79
|
+
if (!SPARDA_TOOLS[name] || !rv || typeof rv !== 'object') continue;
|
|
80
|
+
const r = Number(rv.repeats), m = Number(rv.mismatches);
|
|
81
|
+
if (!Number.isFinite(r) || !Number.isFinite(m) || r < 0 || m < 0) continue;
|
|
82
|
+
const pu = SPARDA_PURITY[name] ?? (SPARDA_PURITY[name] = { sigs: {}, repeats: 0, mismatches: 0 });
|
|
83
|
+
if (r > pu.repeats) pu.repeats = r; // grow-only: a count never shrinks on merge
|
|
84
|
+
if (m > pu.mismatches) pu.mismatches = m; // a mismatch anywhere → volatile everywhere (safe)
|
|
85
|
+
merged += 1;
|
|
86
|
+
}
|
|
87
|
+
return merged;
|
|
88
|
+
}
|
|
89
|
+
// background tick: push our snapshot to every peer. Fire-and-forget, off the request path,
|
|
90
|
+
// AbortSignal-bounded; a peer being down is silence and the next tick retries (R1).
|
|
91
|
+
function spardaGossipTick() {
|
|
92
|
+
if (SPARDA_PEERS.length === 0) return;
|
|
93
|
+
const body = JSON.stringify(spardaGossipSnapshot());
|
|
94
|
+
for (const peer of SPARDA_PEERS) {
|
|
95
|
+
fetch(`${peer}/mcp/gossip`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'content-type': 'application/json', 'x-sparda-key': SPARDA_LOCAL_KEY },
|
|
98
|
+
body,
|
|
99
|
+
signal: AbortSignal.timeout(SPARDA_GOSSIP_TIMEOUT_MS),
|
|
100
|
+
}).catch(() => {}); // peer unreachable → ignore; convergence is eventual, not urgent
|
|
101
|
+
}
|
|
102
|
+
}
|
|
58
103
|
function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, message__ANY_TYPE__) {
|
|
59
104
|
SPARDA_EVENTS.push({ seq: ++SPARDA_SEQ, ts: new Date().toISOString(), source, tool, status, message: String(message ?? '').slice(0, 500) });
|
|
60
105
|
if (SPARDA_EVENTS.length > 100) SPARDA_EVENTS.shift();
|
|
@@ -62,6 +107,20 @@ function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, m
|
|
|
62
107
|
// observe-only: uncaughtExceptionMonitor never changes the host app's crash behavior
|
|
63
108
|
process.on('uncaughtExceptionMonitor', (err__ANY_TYPE__) => spardaEvent('process', null, null, err && err.message ? err.message : err));
|
|
64
109
|
|
|
110
|
+
// two-phase commit: a write/delete flagged require_human is NOT executed on /invoke.
|
|
111
|
+
// It mints a single-use nonce + a preview contract; the host route is touched only after
|
|
112
|
+
// an explicit /invoke/confirm replays the token. This is the gate the proof always described
|
|
113
|
+
// but never enforced — the difference between a receipt and a contract.
|
|
114
|
+
const SPARDA_PENDING__STATS_TYPE__ = {};
|
|
115
|
+
const SPARDA_CONFIRM_TTL_MS = Number(process.env.SPARDA_CONFIRM_TTL_MS ?? 120000);
|
|
116
|
+
function spardaNonce() {
|
|
117
|
+
return 'cfm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
118
|
+
}
|
|
119
|
+
function spardaSweepPending() {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
for (const k of Object.keys(SPARDA_PENDING)) if (now > SPARDA_PENDING[k].expiresAt) delete SPARDA_PENDING[k];
|
|
122
|
+
}
|
|
123
|
+
|
|
65
124
|
function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
|
|
66
125
|
const checks = {
|
|
67
126
|
knownTool: spec !== undefined,
|
|
@@ -190,6 +249,53 @@ function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
|
|
|
190
249
|
};
|
|
191
250
|
}
|
|
192
251
|
|
|
252
|
+
// captured once so the JSON-middleware placeholder appears a single time in the template
|
|
253
|
+
// (the generator's replace is non-global).
|
|
254
|
+
const SPARDA_JSON_MW__ANY_TYPE__ = __JSON_MW__;
|
|
255
|
+
// SPARDA owns its own endpoints' body parsing AND the parse error: a malformed JSON body
|
|
256
|
+
// returns JSON 400 locally instead of bubbling up to Express' HTML error page (stack leak).
|
|
257
|
+
function SPARDA_JSON(req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) {
|
|
258
|
+
SPARDA_JSON_MW(req, res, (err__ANY_TYPE__) => {
|
|
259
|
+
if (err) { spardaEvent('router', null, 400, 'invalid JSON body'); return res.status(400).json({ error: 'invalid JSON body' }); }
|
|
260
|
+
next();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// the actual host-route call, shared by the allow-path and the confirm-path so a
|
|
265
|
+
// confirmed write runs through the exact same execution + observation as a direct one.
|
|
266
|
+
async function spardaExecute(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__, proof__ANY_TYPE__, t0__ANY_TYPE__, res__RES_TYPE__) {
|
|
267
|
+
try {
|
|
268
|
+
let url = spec.path.replace(/:(\w+)/g, (_, name) => encodeURIComponent(String(args[name])));
|
|
269
|
+
const query = [];
|
|
270
|
+
for (const [k, v] of Object.entries(args)) {
|
|
271
|
+
if (k === 'body' || (spec.pathParams ?? []).includes(k) || v === undefined) continue;
|
|
272
|
+
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
273
|
+
}
|
|
274
|
+
if (query.length) url += `?${query.join('&')}`;
|
|
275
|
+
|
|
276
|
+
const init = { method: spec.method, headers: {} };
|
|
277
|
+
if (spec.method !== 'GET' && args.body !== undefined) {
|
|
278
|
+
init.headers['content-type'] = 'application/json';
|
|
279
|
+
init.body = JSON.stringify(args.body);
|
|
280
|
+
}
|
|
281
|
+
SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
|
|
282
|
+
const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
|
|
283
|
+
const text = await upstream.text();
|
|
284
|
+
let data; try { data = JSON.parse(text); } catch { data = text; }
|
|
285
|
+
spardaRecord(tool, upstream.status, Date.now() - t0);
|
|
286
|
+
if (spec.method === 'GET' && upstream.status === 200) {
|
|
287
|
+
// canonical argsig: sorted entries, so the AI's argument order never splits a signature
|
|
288
|
+
spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
|
|
289
|
+
}
|
|
290
|
+
if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
|
|
291
|
+
return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
|
|
292
|
+
} catch (err__ANY_TYPE__) {
|
|
293
|
+
spardaRecord(tool, err.status ?? 502, Date.now() - t0);
|
|
294
|
+
spardaEvent('invoke', tool, err.status ?? 502, err.message);
|
|
295
|
+
return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
193
299
|
__ROUTER_DECL__
|
|
194
300
|
|
|
195
301
|
spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
|
|
@@ -208,9 +314,25 @@ spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
|
208
314
|
res.json({ seq: SPARDA_SEQ, events: SPARDA_EVENTS.filter((e__ANY_TYPE__) => e.seq > since) });
|
|
209
315
|
});
|
|
210
316
|
|
|
211
|
-
|
|
212
|
-
|
|
317
|
+
// peer-to-peer convergence (Brief #2). Behind the same x-sparda-key as every route above
|
|
318
|
+
// (replicas share the generated key). Absorbs a peer's grow-only counts via CRDT max-merge;
|
|
319
|
+
// 204 on success. An unknown tool or malformed count is silently dropped (bounded, no injection).
|
|
320
|
+
spardaRouter.post('/gossip', SPARDA_JSON, (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
321
|
+
spardaMergeGossip(req.body);
|
|
322
|
+
res.status(204).end();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
spardaRouter.post('/invoke', SPARDA_JSON, async (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
213
326
|
const t0 = Date.now();
|
|
327
|
+
const reqBody = req.body ?? {};
|
|
328
|
+
const tool = reqBody.tool;
|
|
329
|
+
// type confusion guard: the old `args = {}` default only caught `undefined`. A `null`
|
|
330
|
+
// (or array/scalar) slipped through and crashed spardaProof on null[name] → Express HTML
|
|
331
|
+
// 500 with a full stack trace. We reject non-objects with clean JSON instead.
|
|
332
|
+
if (reqBody.args !== undefined && (reqBody.args === null || typeof reqBody.args !== 'object' || Array.isArray(reqBody.args))) {
|
|
333
|
+
return res.status(400).json({ error: 'args must be a JSON object', got: reqBody.args === null ? 'null' : Array.isArray(reqBody.args) ? 'array' : typeof reqBody.args });
|
|
334
|
+
}
|
|
335
|
+
const args = reqBody.args ?? {};
|
|
214
336
|
const spec = SPARDA_TOOLS[tool];
|
|
215
337
|
|
|
216
338
|
const proof = spardaProof(tool, spec, args);
|
|
@@ -249,41 +371,69 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
|
|
|
249
371
|
return res.status(status).json({ error, spardingProof: proof });
|
|
250
372
|
}
|
|
251
373
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
374
|
+
if (proof.decision === 'require_human') {
|
|
375
|
+
// THE GATE THE PROOF ALWAYS LACKED.
|
|
376
|
+
// v0.5.0 had no branch here, so a require_human decision fell straight through to the
|
|
377
|
+
// host route below: the write happened, THEN the proof said "confirmation required".
|
|
378
|
+
// We stop here. The host route is not touched. We mint a single-use nonce and a preview
|
|
379
|
+
// the LLM reads in its tool-result, and only /invoke/confirm replays it.
|
|
380
|
+
spardaSweepPending();
|
|
381
|
+
const confirm = spardaNonce();
|
|
382
|
+
SPARDA_PENDING[confirm] = { tool, args, expiresAt: Date.now() + SPARDA_CONFIRM_TTL_MS, createdAt: new Date().toISOString() };
|
|
383
|
+
let siblingName = null;
|
|
384
|
+
for (const [n, t] of Object.entries(SPARDA_TOOLS)) { if (t.method === 'GET' && t.path === spec.path) { siblingName = n; break; } }
|
|
385
|
+
spardaEvent('confirm', tool, 202, `gated, awaiting confirmation (risk: ${proof.risk})`);
|
|
386
|
+
return res.status(202).json({
|
|
387
|
+
status: 'awaiting_confirmation',
|
|
388
|
+
confirm,
|
|
389
|
+
expiresInMs: SPARDA_CONFIRM_TTL_MS,
|
|
390
|
+
preview: {
|
|
391
|
+
tool,
|
|
392
|
+
method: spec.method,
|
|
393
|
+
path: spec.path,
|
|
394
|
+
willSendBody: spec.method !== 'GET' && args.body !== undefined,
|
|
395
|
+
reversibleHint: proof.checks.reversibleHint,
|
|
396
|
+
inspectFirst: siblingName ? { tool: siblingName, why: 'GET on the same path — read current state before committing this write' } : null,
|
|
397
|
+
},
|
|
398
|
+
// this string is what the LLM reads: the security decision IS the instruction.
|
|
399
|
+
instruction: `NOT EXECUTED. ${spec.method} ${spec.path} is gated by policy (${proof.reasons[0] || 'require_human'}); the host route was not touched.${siblingName ? ` First call "${siblingName}" to inspect current state, then` : ' To'} confirm via POST /invoke/confirm { "confirm": "${confirm}" }. Single-use, expires in ${Math.round(SPARDA_CONFIRM_TTL_MS / 1000)}s.`,
|
|
400
|
+
spardingProof: proof,
|
|
256
401
|
});
|
|
257
|
-
|
|
258
|
-
for (const [k, v] of Object.entries(args)) {
|
|
259
|
-
if (k === 'body' || spec.pathParams.includes(k) || v === undefined) continue;
|
|
260
|
-
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
261
|
-
}
|
|
262
|
-
if (query.length) url += `?${query.join('&')}`;
|
|
402
|
+
}
|
|
263
403
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
spardaRecord(tool, upstream.status, Date.now() - t0);
|
|
274
|
-
if (spec.method === 'GET' && upstream.status === 200) {
|
|
275
|
-
// canonical argsig: sorted entries, so the AI's argument order never splits a signature
|
|
276
|
-
spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
|
|
277
|
-
}
|
|
278
|
-
if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
|
|
279
|
-
return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
|
|
280
|
-
} catch (err__ANY_TYPE__) {
|
|
281
|
-
spardaRecord(tool, err.status ?? 502, Date.now() - t0);
|
|
282
|
-
spardaEvent('invoke', tool, err.status ?? 502, err.message);
|
|
283
|
-
return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
|
|
404
|
+
// proof.decision === 'allow' — exercise the host route
|
|
405
|
+
return spardaExecute(tool, spec, args, proof, t0, res);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
spardaRouter.post('/invoke/confirm', SPARDA_JSON, async (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
409
|
+
const t0 = Date.now();
|
|
410
|
+
const confirm = (req.body ?? {}).confirm;
|
|
411
|
+
if (typeof confirm !== 'string' || !confirm) {
|
|
412
|
+
return res.status(400).json({ error: 'missing confirm token (expected { confirm: "..." })' });
|
|
284
413
|
}
|
|
414
|
+
spardaSweepPending();
|
|
415
|
+
const pending = SPARDA_PENDING[confirm];
|
|
416
|
+
if (pending) delete SPARDA_PENDING[confirm]; // consume first — a token can never replay
|
|
417
|
+
if (!pending) {
|
|
418
|
+
return res.status(409).json({ error: 'unknown or expired confirmation token', hint: 're-issue the write via /invoke to mint a fresh token' });
|
|
419
|
+
}
|
|
420
|
+
if (Date.now() > pending.expiresAt) {
|
|
421
|
+
return res.status(409).json({ error: 'confirmation token expired', hint: 're-issue the write via /invoke' });
|
|
422
|
+
}
|
|
423
|
+
const spec = SPARDA_TOOLS[pending.tool];
|
|
424
|
+
// re-judge at commit time: the tool may have been quarantined or disabled since minting
|
|
425
|
+
const proof = spardaProof(pending.tool, spec, pending.args);
|
|
426
|
+
if (proof.decision === 'block') {
|
|
427
|
+
return res.status(403).json({ error: 'confirmation no longer valid', reason: proof.reasons[0] || 'blocked', spardingProof: proof });
|
|
428
|
+
}
|
|
429
|
+
spardaEvent('confirm', pending.tool, 200, 'confirmed -> executing');
|
|
430
|
+
return spardaExecute(pending.tool, spec, pending.args, proof, t0, res);
|
|
285
431
|
});
|
|
286
432
|
|
|
433
|
+
// verb safety: any non-POST on the invoke endpoints returns JSON 405 (was bare Express HTML — bug #5)
|
|
434
|
+
spardaRouter.all('/invoke', (req__REQ_TYPE__, res__RES_TYPE__) => res.status(405).json({ error: 'method not allowed', allow: 'POST' }));
|
|
435
|
+
spardaRouter.all('/invoke/confirm', (req__REQ_TYPE__, res__RES_TYPE__) => res.status(405).json({ error: 'method not allowed', allow: 'POST' }));
|
|
436
|
+
|
|
287
437
|
function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
|
|
288
438
|
const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
|
|
289
439
|
// innate immunity: latency far beyond the learned baseline is an antigen
|
|
@@ -304,3 +454,24 @@ function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
|
|
|
304
454
|
spardaEvent('immune', tool, 503, `quarantined after ${st.consecutive5xx} consecutive 5xx (cooldown ${SPARDA_QUARANTINE_MS}ms)`);
|
|
305
455
|
}
|
|
306
456
|
}
|
|
457
|
+
|
|
458
|
+
// anything unmatched under the router → JSON 404, never bare Express HTML
|
|
459
|
+
spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__) => res.status(404).json({ error: 'not found', path: req.path }));
|
|
460
|
+
|
|
461
|
+
// error envelope (must stay last). body-parser throws SyntaxError on malformed JSON, and
|
|
462
|
+
// anything thrown downstream used to fall through to Express' HTML error page — leaking the
|
|
463
|
+
// internal stack trace. Every response stays JSON; the stack stays server-side, correlated
|
|
464
|
+
// to /events by errorId.
|
|
465
|
+
spardaRouter.use((err__ANY_TYPE__, req__REQ_TYPE__, res__RES_TYPE__, _next__NEXT_TYPE__) => {
|
|
466
|
+
const errorId = 'err_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
467
|
+
spardaEvent('router', null, (err && err.status) || 500, `[${errorId}] ${err && err.message ? err.message : err}`);
|
|
468
|
+
const status = err && (err.type === 'entity.parse.failed' || err instanceof SyntaxError) ? 400 : (err && err.status) || 500;
|
|
469
|
+
res.status(status).json({ error: status === 400 ? 'invalid JSON body' : 'internal error', errorId });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// start gossiping only when peers are configured — solo deployments pay nothing (zero infra).
|
|
473
|
+
// .unref(): the tick timer never keeps the host process alive on its own.
|
|
474
|
+
if (SPARDA_PEERS.length > 0) {
|
|
475
|
+
spardaEvent('gossip', null, null, `horizontal scale: gossiping to ${SPARDA_PEERS.length} peer(s) every ${SPARDA_GOSSIP_MS}ms`);
|
|
476
|
+
setInterval(spardaGossipTick, SPARDA_GOSSIP_MS).unref();
|
|
477
|
+
}
|
|
@@ -14,6 +14,7 @@ import time
|
|
|
14
14
|
import datetime
|
|
15
15
|
import os
|
|
16
16
|
import zlib
|
|
17
|
+
import threading
|
|
17
18
|
|
|
18
19
|
# json.loads, not a Python literal: JSON true/false/null are not valid Python (E-009)
|
|
19
20
|
SPARDA_TOOLS = json.loads(__TOOLS_JSON__)
|
|
@@ -32,6 +33,23 @@ SPARDA_QUARANTINE_MS = int(os.environ.get("SPARDA_QUARANTINE_MS", "60000"))
|
|
|
32
33
|
# Day 1 it reads 0% — the circle fills with usage (a measure, never a promise).
|
|
33
34
|
SPARDA_RECYCLE = {"servedByCircle": 0, "paidFull": 0}
|
|
34
35
|
|
|
36
|
+
# two-phase commit: a write/delete flagged require_human is NOT executed on /invoke.
|
|
37
|
+
# It mints a single-use nonce + a preview contract; the host route is touched only after
|
|
38
|
+
# an explicit /invoke/confirm replays the token.
|
|
39
|
+
SPARDA_PENDING = {}
|
|
40
|
+
SPARDA_CONFIRM_TTL_MS = int(os.environ.get("SPARDA_CONFIRM_TTL_MS", "120000"))
|
|
41
|
+
|
|
42
|
+
def sparda_nonce():
|
|
43
|
+
import base64
|
|
44
|
+
import uuid
|
|
45
|
+
return "cfm_" + base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("ascii").rstrip("=")
|
|
46
|
+
|
|
47
|
+
def sparda_sweep_pending():
|
|
48
|
+
now = time.time() * 1000
|
|
49
|
+
expired = [k for k, v in SPARDA_PENDING.items() if now > v["expiresAt"]]
|
|
50
|
+
for k in expired:
|
|
51
|
+
del SPARDA_PENDING[k]
|
|
52
|
+
|
|
35
53
|
def sparda_recycle_rate():
|
|
36
54
|
total = SPARDA_RECYCLE["servedByCircle"] + SPARDA_RECYCLE["paidFull"]
|
|
37
55
|
return round(SPARDA_RECYCLE["servedByCircle"] * 100 / total) if total else 0
|
|
@@ -43,7 +61,10 @@ SPARDA_PURITY = {}
|
|
|
43
61
|
|
|
44
62
|
def sparda_observe_purity(tool, argsig, body_bytes):
|
|
45
63
|
pu = SPARDA_PURITY.setdefault(tool, {"sigs": {}, "repeats": 0, "mismatches": 0})
|
|
46
|
-
|
|
64
|
+
# memoryview slice is zero-copy: crc32 reads the first 64KB in place, with no
|
|
65
|
+
# transient 64KB bytes copy per GET+200 (HFT technique 3, the one that fits the
|
|
66
|
+
# proxy router — same fingerprint, one fewer allocation on the response path).
|
|
67
|
+
h = zlib.crc32(memoryview(body_bytes)[:65536]) # a fingerprint, not a checksum of megabytes
|
|
47
68
|
known = pu["sigs"].get(argsig)
|
|
48
69
|
if known is None:
|
|
49
70
|
if len(pu["sigs"]) >= 20: # bounded: enough sigs to judge
|
|
@@ -73,6 +94,74 @@ def sparda_purity_snapshot():
|
|
|
73
94
|
out[name] = {"class": cls, "repeats": pu["repeats"] if pu else 0, "mismatches": pu["mismatches"] if pu else 0}
|
|
74
95
|
return out
|
|
75
96
|
|
|
97
|
+
# ── horizontal scale (Brief #2): Quorum Sensing × G-Map CRDT ──────────────────
|
|
98
|
+
# Same mechanism as the Express router, Python side. N replicas gossip their grow-only
|
|
99
|
+
# purity counts so a route observed-pure on ANY replica converges everywhere — no
|
|
100
|
+
# coordinator, no store, stdlib only. Solo (no SPARDA_PEERS) starts no thread, costs
|
|
101
|
+
# nothing. Merge = max() per counter (commutative/associative/idempotent). value-free:
|
|
102
|
+
# only tool NAMES (already public via /tools) and integer counts cross the wire.
|
|
103
|
+
SPARDA_PEERS = [p.strip().rstrip("/") for p in os.environ.get("SPARDA_PEERS", "").split(",") if p.strip().startswith("http")]
|
|
104
|
+
try:
|
|
105
|
+
SPARDA_GOSSIP_INTERVAL = max(50, int(os.environ.get("SPARDA_GOSSIP_MS", "30000"))) / 1000.0
|
|
106
|
+
except ValueError:
|
|
107
|
+
SPARDA_GOSSIP_INTERVAL = 30.0
|
|
108
|
+
try:
|
|
109
|
+
SPARDA_GOSSIP_TIMEOUT = max(1, int(os.environ.get("SPARDA_GOSSIP_TIMEOUT_MS", "2000")) // 1000)
|
|
110
|
+
except ValueError:
|
|
111
|
+
SPARDA_GOSSIP_TIMEOUT = 2
|
|
112
|
+
|
|
113
|
+
def sparda_gossip_snapshot():
|
|
114
|
+
# per-tool {repeats, mismatches} — both monotonic, safe to max-merge.
|
|
115
|
+
# dict(SPARDA_PURITY) is an atomic C-level copy under the GIL: the push runs on a daemon
|
|
116
|
+
# thread while the event loop mutates SPARDA_PURITY, so we never iterate the live dict
|
|
117
|
+
# (that would risk "dictionary changed size during iteration"). The int reads are atomic.
|
|
118
|
+
return {name: {"repeats": pu["repeats"], "mismatches": pu["mismatches"]} for name, pu in dict(SPARDA_PURITY).items()}
|
|
119
|
+
|
|
120
|
+
def sparda_merge_gossip(remote):
|
|
121
|
+
# max() per counter, ONLY for known tools (bounded; unknown/injected keys ignored).
|
|
122
|
+
# bool is a subclass of int in Python — reject it so a JSON `true` can't pose as a count.
|
|
123
|
+
if not isinstance(remote, dict):
|
|
124
|
+
return 0
|
|
125
|
+
merged = 0
|
|
126
|
+
for name, rv in remote.items():
|
|
127
|
+
if name not in SPARDA_TOOLS or not isinstance(rv, dict):
|
|
128
|
+
continue
|
|
129
|
+
r = rv.get("repeats")
|
|
130
|
+
m = rv.get("mismatches")
|
|
131
|
+
if not isinstance(r, int) or isinstance(r, bool) or r < 0:
|
|
132
|
+
continue
|
|
133
|
+
if not isinstance(m, int) or isinstance(m, bool) or m < 0:
|
|
134
|
+
continue
|
|
135
|
+
pu = SPARDA_PURITY.setdefault(name, {"sigs": {}, "repeats": 0, "mismatches": 0})
|
|
136
|
+
if r > pu["repeats"]:
|
|
137
|
+
pu["repeats"] = r # grow-only: never shrinks on merge
|
|
138
|
+
if m > pu["mismatches"]:
|
|
139
|
+
pu["mismatches"] = m # a mismatch anywhere → volatile everywhere (safe)
|
|
140
|
+
merged += 1
|
|
141
|
+
return merged
|
|
142
|
+
|
|
143
|
+
def sparda_gossip_push():
|
|
144
|
+
# one round: push our snapshot to every peer. Fire-and-forget, off the request path.
|
|
145
|
+
if not SPARDA_PEERS:
|
|
146
|
+
return
|
|
147
|
+
payload = json.dumps(sparda_gossip_snapshot()).encode("utf-8")
|
|
148
|
+
for peer in SPARDA_PEERS:
|
|
149
|
+
try:
|
|
150
|
+
req = urllib.request.Request(
|
|
151
|
+
peer + "/mcp/gossip",
|
|
152
|
+
data=payload,
|
|
153
|
+
headers={"content-type": "application/json", "x-sparda-key": SPARDA_LOCAL_KEY},
|
|
154
|
+
method="POST",
|
|
155
|
+
)
|
|
156
|
+
urllib.request.urlopen(req, timeout=SPARDA_GOSSIP_TIMEOUT).close()
|
|
157
|
+
except Exception:
|
|
158
|
+
pass # peer down → silence; the next tick retries (eventual convergence)
|
|
159
|
+
|
|
160
|
+
def _sparda_gossip_loop():
|
|
161
|
+
while True:
|
|
162
|
+
time.sleep(SPARDA_GOSSIP_INTERVAL)
|
|
163
|
+
sparda_gossip_push()
|
|
164
|
+
|
|
76
165
|
def sparda_event(source, tool, status, message):
|
|
77
166
|
global SPARDA_SEQ
|
|
78
167
|
SPARDA_SEQ += 1
|
|
@@ -263,6 +352,20 @@ async def get_events(request: Request):
|
|
|
263
352
|
since = 0
|
|
264
353
|
return {"seq": SPARDA_SEQ, "events": [e for e in SPARDA_EVENTS if e["seq"] > since]}
|
|
265
354
|
|
|
355
|
+
@sparda_router.post("/gossip")
|
|
356
|
+
async def gossip(request: Request):
|
|
357
|
+
# peer-to-peer convergence (Brief #2), behind the same x-sparda-key as every route.
|
|
358
|
+
# Absorbs a peer's grow-only counts via CRDT max-merge; 204 on success. An unknown tool
|
|
359
|
+
# or malformed count is silently dropped (bounded, no injection).
|
|
360
|
+
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
361
|
+
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
362
|
+
try:
|
|
363
|
+
body = await request.json()
|
|
364
|
+
except Exception:
|
|
365
|
+
body = {}
|
|
366
|
+
sparda_merge_gossip(body)
|
|
367
|
+
return Response(status_code=204)
|
|
368
|
+
|
|
266
369
|
@sparda_router.post("/invoke")
|
|
267
370
|
async def invoke_tool(request: Request):
|
|
268
371
|
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
@@ -314,6 +417,45 @@ async def invoke_tool(request: Request):
|
|
|
314
417
|
status = 403
|
|
315
418
|
return JSONResponse(status_code=status, content={"error": error, "spardingProof": proof})
|
|
316
419
|
|
|
420
|
+
if proof["decision"] == "require_human":
|
|
421
|
+
sparda_sweep_pending()
|
|
422
|
+
confirm = sparda_nonce()
|
|
423
|
+
SPARDA_PENDING[confirm] = {
|
|
424
|
+
"tool": tool,
|
|
425
|
+
"args": args,
|
|
426
|
+
"expiresAt": time.time() * 1000 + SPARDA_CONFIRM_TTL_MS,
|
|
427
|
+
"createdAt": datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
428
|
+
}
|
|
429
|
+
sibling_name = None
|
|
430
|
+
for n, t in SPARDA_TOOLS.items():
|
|
431
|
+
if t.get("method") == "GET" and t.get("path") == spec.get("path"):
|
|
432
|
+
sibling_name = n
|
|
433
|
+
break
|
|
434
|
+
# Build the conditional hint OUTSIDE the instruction f-string below: on
|
|
435
|
+
# Python < 3.12 an f-string expression part may not contain a backslash
|
|
436
|
+
# or the delimiting quote (PEP 701 lifted this in 3.12). Pre-computing it
|
|
437
|
+
# here keeps the generated router valid on every supported Python (3.9+).
|
|
438
|
+
sparda_hint = (
|
|
439
|
+
f' First call "{sibling_name}" to inspect current state, then'
|
|
440
|
+
if sibling_name else ' To'
|
|
441
|
+
)
|
|
442
|
+
sparda_event("confirm", tool, 202, f"gated, awaiting confirmation (risk: {proof['risk']})")
|
|
443
|
+
return JSONResponse(status_code=202, content={
|
|
444
|
+
"status": "awaiting_confirmation",
|
|
445
|
+
"confirm": confirm,
|
|
446
|
+
"expiresInMs": SPARDA_CONFIRM_TTL_MS,
|
|
447
|
+
"preview": {
|
|
448
|
+
"tool": tool,
|
|
449
|
+
"method": spec.get("method"),
|
|
450
|
+
"path": spec.get("path"),
|
|
451
|
+
"willSendBody": spec.get("method") != "GET" and args.get("body") is not None,
|
|
452
|
+
"reversibleHint": proof["checks"]["reversibleHint"],
|
|
453
|
+
"inspectFirst": {"tool": sibling_name, "why": "GET on the same path — read current state before committing this write"} if sibling_name else None,
|
|
454
|
+
},
|
|
455
|
+
"instruction": f"NOT EXECUTED. {spec.get('method')} {spec.get('path')} is gated by policy ({proof['reasons'][0] if proof['reasons'] else 'require_human'}); the host route was not touched.{sparda_hint} confirm via POST /invoke/confirm {{ \"confirm\": \"{confirm}\" }}. Single-use, expires in {round(SPARDA_CONFIRM_TTL_MS / 1000)}s.",
|
|
456
|
+
"spardingProof": proof,
|
|
457
|
+
})
|
|
458
|
+
|
|
317
459
|
t0 = time.time()
|
|
318
460
|
try:
|
|
319
461
|
path_params = spec.get("pathParams", [])
|
|
@@ -369,3 +511,98 @@ async def invoke_tool(request: Request):
|
|
|
369
511
|
sparda_record(tool, 502, round((time.time() - t0) * 1000))
|
|
370
512
|
sparda_event("invoke", tool, 502, str(e))
|
|
371
513
|
return JSONResponse(status_code=502, content={"error": str(e), "spardingProof": proof})
|
|
514
|
+
|
|
515
|
+
@sparda_router.post("/invoke/confirm")
|
|
516
|
+
async def confirm_tool_invocation(request: Request):
|
|
517
|
+
if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
|
|
518
|
+
return JSONResponse(status_code=401, content={"error": "unauthorized"})
|
|
519
|
+
|
|
520
|
+
t0 = time.time()
|
|
521
|
+
try:
|
|
522
|
+
body = await request.json()
|
|
523
|
+
except Exception:
|
|
524
|
+
body = {}
|
|
525
|
+
|
|
526
|
+
confirm = body.get("confirm")
|
|
527
|
+
if not isinstance(confirm, str) or not confirm:
|
|
528
|
+
return JSONResponse(status_code=400, content={"error": "missing confirm token (expected { confirm: \"...\" })"})
|
|
529
|
+
|
|
530
|
+
sparda_sweep_pending()
|
|
531
|
+
pending = SPARDA_PENDING.get(confirm)
|
|
532
|
+
if pending:
|
|
533
|
+
del SPARDA_PENDING[confirm] # consume first
|
|
534
|
+
|
|
535
|
+
if not pending:
|
|
536
|
+
return JSONResponse(status_code=409, content={"error": "unknown or expired confirmation token", "hint": "re-issue the write via /invoke to mint a fresh token"})
|
|
537
|
+
|
|
538
|
+
if time.time() * 1000 > pending["expiresAt"]:
|
|
539
|
+
return JSONResponse(status_code=409, content={"error": "confirmation token expired", "hint": "re-issue the write via /invoke"})
|
|
540
|
+
|
|
541
|
+
spec = SPARDA_TOOLS.get(pending["tool"])
|
|
542
|
+
proof = sparda_proof(pending["tool"], spec, pending["args"])
|
|
543
|
+
|
|
544
|
+
if proof["decision"] == "block":
|
|
545
|
+
return JSONResponse(status_code=403, content={"error": "confirmation no longer valid", "reason": proof["reasons"][0] if proof["reasons"] else "blocked", "spardingProof": proof})
|
|
546
|
+
|
|
547
|
+
sparda_event("confirm", pending["tool"], 200, "confirmed -> executing")
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
path_params = spec.get("pathParams", [])
|
|
551
|
+
path = spec.get("path")
|
|
552
|
+
|
|
553
|
+
def repl(match):
|
|
554
|
+
name = match.group(1)
|
|
555
|
+
val = pending["args"].get(name)
|
|
556
|
+
return urllib.parse.quote(str(val))
|
|
557
|
+
|
|
558
|
+
resolved_path = re.sub(r'\{(\w+)\}', repl, path)
|
|
559
|
+
|
|
560
|
+
query_params = []
|
|
561
|
+
for k, v in pending["args"].items():
|
|
562
|
+
if k == 'body' or k in path_params or v is None:
|
|
563
|
+
continue
|
|
564
|
+
query_params.append(f"{urllib.parse.quote(k)}={urllib.parse.quote(str(v))}")
|
|
565
|
+
|
|
566
|
+
if query_params:
|
|
567
|
+
resolved_path += "?" + "&".join(query_params)
|
|
568
|
+
|
|
569
|
+
url = f"http://127.0.0.1:{SPARDA_PORT}{resolved_path}"
|
|
570
|
+
method = spec.get("method", "GET")
|
|
571
|
+
|
|
572
|
+
headers = {}
|
|
573
|
+
body_bytes = None
|
|
574
|
+
|
|
575
|
+
if method != "GET" and pending["args"].get("body") is not None:
|
|
576
|
+
headers["content-type"] = "application/json"
|
|
577
|
+
body_bytes = json.dumps(pending["args"]["body"]).encode("utf-8")
|
|
578
|
+
|
|
579
|
+
SPARDA_RECYCLE["paidFull"] += 1
|
|
580
|
+
loop = asyncio.get_running_loop()
|
|
581
|
+
status, content_type, data_bytes = await loop.run_in_executor(
|
|
582
|
+
executor, sync_fetch, url, method, headers, body_bytes
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
text_data = data_bytes.decode("utf-8", errors="ignore")
|
|
586
|
+
try:
|
|
587
|
+
data = json.loads(text_data)
|
|
588
|
+
except Exception:
|
|
589
|
+
data = text_data
|
|
590
|
+
|
|
591
|
+
sparda_record(pending["tool"], status, round((time.time() - t0) * 1000))
|
|
592
|
+
if method == "GET" and status == 200:
|
|
593
|
+
argsig = json.dumps(sorted((k, str(v)) for k, v in pending["args"].items() if v is not None))
|
|
594
|
+
sparda_observe_purity(pending["tool"], argsig, data_bytes)
|
|
595
|
+
if status >= 500:
|
|
596
|
+
sparda_event("invoke", pending["tool"], status, text_data[:200])
|
|
597
|
+
return {"upstreamStatus": status, "data": data, "spardingProof": proof}
|
|
598
|
+
except Exception as e:
|
|
599
|
+
sparda_record(pending["tool"], 502, round((time.time() - t0) * 1000))
|
|
600
|
+
sparda_event("invoke", pending["tool"], 502, str(e))
|
|
601
|
+
return JSONResponse(status_code=502, content={"error": str(e), "spardingProof": proof})
|
|
602
|
+
|
|
603
|
+
# start gossiping only when peers are configured — solo deployments pay nothing (zero infra).
|
|
604
|
+
# daemon=True: the background thread never blocks interpreter shutdown.
|
|
605
|
+
if SPARDA_PEERS:
|
|
606
|
+
sparda_event("gossip", None, None, f"horizontal scale: gossiping to {len(SPARDA_PEERS)} peer(s) every {SPARDA_GOSSIP_INTERVAL}s")
|
|
607
|
+
threading.Thread(target=_sparda_gossip_loop, daemon=True).start()
|
|
608
|
+
|