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.
@@ -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
- spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE__) => {
212
- const { tool, args = {} } = req.body ?? {};
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
- try {
253
- let url = spec.path.replace(/:(\w+)/g, (_, name) => {
254
- const v = args[name];
255
- return encodeURIComponent(String(v));
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
- const query = [];
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
- const init = { method: spec.method, headers: {} };
265
- if (spec.method !== 'GET' && args.body !== undefined) {
266
- init.headers['content-type'] = 'application/json';
267
- init.body = JSON.stringify(args.body);
268
- }
269
- SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
270
- const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
271
- const text = await upstream.text();
272
- let data; try { data = JSON.parse(text); } catch { data = text; }
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
- h = zlib.crc32(body_bytes[:65536]) # a fingerprint, not a checksum of megabytes
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
+