sparda-mcp 0.4.0 → 0.5.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parse } from '@babel/parser';
7
7
  import { toolNameFor } from '../parser/express.js';
8
- import { carryOverManifest } from './manifest.js';
8
+ import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const MARK_START = '// >>> sparda-injection (do not edit this block) >>>';
@@ -28,6 +28,28 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
28
28
  }
29
29
  const prev = carryOverManifest(cwd, tools);
30
30
 
31
+ // --- sparding safety memory & fingerprints
32
+ const sparding = defaultSpardingMemory(prev);
33
+ const oldFingerprints = sparding.toolFingerprints ?? {};
34
+ const newFingerprints = {};
35
+ for (const [name, t] of Object.entries(tools)) {
36
+ const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
37
+ const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
38
+ newFingerprints[name] = fp;
39
+ const oldFp = oldFingerprints[name];
40
+ if (oldFp && oldFp !== fp) {
41
+ sparding.events.push({
42
+ ts: new Date().toISOString(),
43
+ tool: name,
44
+ decision: 'audit',
45
+ risk: 'medium',
46
+ reasons: [`route structure modified (fingerprint changed from ${oldFp} to ${fp})`],
47
+ });
48
+ if (sparding.events.length > 100) sparding.events.shift();
49
+ }
50
+ }
51
+ sparding.toolFingerprints = newFingerprints;
52
+
31
53
  // stable across re-runs so a running bridge/host pair never desyncs
32
54
  const localKey = prev?.localKey ?? crypto.randomUUID();
33
55
  const entryAbs = path.resolve(cwd, entryFile);
@@ -61,6 +83,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
61
83
 
62
84
  tpl = tpl
63
85
  .replace('__IMPORT_LINE__', importLine)
86
+ .replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
64
87
  .replace('__ROUTER_DECL__', isESM
65
88
  ? 'export const spardaRouter = express.Router();'
66
89
  : 'const spardaRouter = express.Router();\nmodule.exports = { spardaRouter };')
@@ -98,6 +121,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
98
121
  ...(prev?.semantic ? { semantic: prev.semantic } : {}),
99
122
  ...(prev?.immune ? { immune: prev.immune } : {}),
100
123
  ...(prev?.labs ? { labs: prev.labs } : {}),
124
+ sparding,
101
125
  };
102
126
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
103
127
 
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { toolNameFor } from '../parser/express.js';
8
- import { carryOverManifest } from './manifest.js';
8
+ import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const MARK_START = '# >>> sparda-injection (do not edit this block) >>>';
@@ -28,6 +28,28 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
28
28
  }
29
29
  const prev = carryOverManifest(cwd, tools);
30
30
 
31
+ // --- sparding safety memory & fingerprints
32
+ const sparding = defaultSpardingMemory(prev);
33
+ const oldFingerprints = sparding.toolFingerprints ?? {};
34
+ const newFingerprints = {};
35
+ for (const [name, t] of Object.entries(tools)) {
36
+ const raw = `${t.method}|${t.path}|${JSON.stringify(t.params)}`;
37
+ const fp = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
38
+ newFingerprints[name] = fp;
39
+ const oldFp = oldFingerprints[name];
40
+ if (oldFp && oldFp !== fp) {
41
+ sparding.events.push({
42
+ ts: new Date().toISOString(),
43
+ tool: name,
44
+ decision: 'audit',
45
+ risk: 'medium',
46
+ reasons: [`route structure modified (fingerprint changed from ${oldFp} to ${fp})`],
47
+ });
48
+ if (sparding.events.length > 100) sparding.events.shift();
49
+ }
50
+ }
51
+ sparding.toolFingerprints = newFingerprints;
52
+
31
53
  // stable across re-runs so a running bridge/host pair never desyncs
32
54
  const localKey = prev?.localKey ?? crypto.randomUUID();
33
55
  const entryAbs = path.resolve(cwd, entryFile);
@@ -41,6 +63,7 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
41
63
  // double-stringify: a JSON string literal is also a valid Python string literal,
42
64
  // and json.loads() in the template turns it into real Python values (E-009)
43
65
  .replace('__TOOLS_JSON__', JSON.stringify(JSON.stringify(tools)))
66
+ .replace('__SPARDING_POLICIES__', JSON.stringify(JSON.stringify(sparding.policies ?? {})))
44
67
  .replace('__LOCAL_KEY__', localKey)
45
68
  .replace('__PORT__', String(port));
46
69
 
@@ -67,6 +90,7 @@ export function generateFastAPI({ cwd, entryFile, port, routes, entryAppVars, py
67
90
  ...(prev?.semantic ? { semantic: prev.semantic } : {}),
68
91
  ...(prev?.immune ? { immune: prev.immune } : {}),
69
92
  ...(prev?.labs ? { labs: prev.labs } : {}),
93
+ sparding,
70
94
  };
71
95
 
72
96
  atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
@@ -4,9 +4,9 @@ import path from 'node:path';
4
4
 
5
5
  // Re-running init must not wipe what the user (or the semantic/immune/Labs
6
6
  // passes) wrote into sparda.json: per-tool `enabled` overrides, the cached
7
- // `semantic` enrichment, the `immune` memory (antibodies) and the `labs`
8
- // state (opt-in flags + observed circuits) survive as long as the tool's
9
- // method+path are unchanged.
7
+ // `semantic` enrichment, the `immune` memory (antibodies), the `labs`
8
+ // state (opt-in flags + observed circuits), and the `sparding` security
9
+ // memory survive as long as the tool's method+path are unchanged.
10
10
  export function carryOverManifest(cwd, tools) {
11
11
  let prev = null;
12
12
  try {
@@ -22,3 +22,18 @@ export function carryOverManifest(cwd, tools) {
22
22
  }
23
23
  return prev;
24
24
  }
25
+
26
+ export function defaultSpardingMemory(prev) {
27
+ return prev?.sparding ?? {
28
+ version: 1,
29
+ policies: {
30
+ reads: 'allow',
31
+ writes: 'require_human',
32
+ deletes: 'block',
33
+ requireProofAfterWrite: true,
34
+ },
35
+ events: [],
36
+ failures: {},
37
+ toolFingerprints: {},
38
+ };
39
+ }
@@ -110,7 +110,7 @@ export function parseExpressProject(cwd, entryFile) {
110
110
  }
111
111
 
112
112
  const fullPath = joinPath(prefix, pathArg.value);
113
- if (fullPath.startsWith('/mcp')) {
113
+ if (fullPath === '/mcp' || fullPath.startsWith('/mcp/')) {
114
114
  skipped.push({ reason: `self-referential path ${fullPath} blocked`, file: rel(absFile), line: p.node.loc?.start.line });
115
115
  return;
116
116
  }
@@ -27,6 +27,77 @@ class RouteSpecExtractor:
27
27
  lines = [line.strip() for line in doc.splitlines()]
28
28
  return " ".join([l for l in lines[:3] if l]).strip()
29
29
 
30
+ def _is_depends(self, default_node):
31
+ """Returns True if the default value is a Depends(...) call."""
32
+ return (
33
+ isinstance(default_node, ast.Call) and
34
+ ((isinstance(default_node.func, ast.Name) and default_node.func.id == 'Depends') or
35
+ (isinstance(default_node.func, ast.Attribute) and default_node.func.attr == 'Depends'))
36
+ )
37
+
38
+ def _collect_all_imports(self, start_file):
39
+ to_visit = [start_file]
40
+ collected = set()
41
+ visited_imports = set()
42
+
43
+ while to_visit:
44
+ curr = to_visit.pop(0)
45
+ if curr in visited_imports:
46
+ continue
47
+ visited_imports.add(curr)
48
+
49
+ if not os.path.exists(curr):
50
+ continue
51
+
52
+ try:
53
+ with open(curr, 'r', encoding='utf-8') as f:
54
+ src = f.read()
55
+ tree = ast.parse(src, filename=curr)
56
+ except:
57
+ continue
58
+
59
+ for node in ast.walk(tree):
60
+ resolved = None
61
+ if isinstance(node, ast.ImportFrom):
62
+ dots = '.' * node.level if node.level > 0 else ''
63
+ module_with_dots = f"{dots}{node.module}" if node.module else dots
64
+
65
+ for name_node in node.names:
66
+ if node.module:
67
+ full_mod = f"{module_with_dots}.{name_node.name}"
68
+ else:
69
+ full_mod = f"{module_with_dots}{name_node.name}"
70
+ resolved = self.resolve_import(curr, full_mod)
71
+ if resolved and resolved not in collected:
72
+ collected.add(resolved)
73
+ to_visit.append(resolved)
74
+
75
+ resolved = self.resolve_import(curr, module_with_dots)
76
+ if resolved and resolved not in collected:
77
+ collected.add(resolved)
78
+ to_visit.append(resolved)
79
+
80
+ elif isinstance(node, ast.Import):
81
+ for name_node in node.names:
82
+ resolved = self.resolve_import(curr, name_node.name)
83
+ if resolved and resolved not in collected:
84
+ collected.add(resolved)
85
+ to_visit.append(resolved)
86
+
87
+ return collected
88
+
89
+ def preload_models(self):
90
+ """Pre-scan all reachable files for Pydantic models before route extraction."""
91
+ for abs_path in self._collect_all_imports(self.entry_file):
92
+ if abs_path not in self.models:
93
+ try:
94
+ with open(abs_path, 'r', encoding='utf-8') as f:
95
+ src = f.read()
96
+ tree = ast.parse(src, filename=abs_path)
97
+ self.extract_pydantic_models(tree, abs_path)
98
+ except:
99
+ pass
100
+
30
101
  def resolve_import(self, from_file, module_name):
31
102
  if not module_name:
32
103
  return None
@@ -176,10 +247,22 @@ class RouteSpecExtractor:
176
247
  for node in tree.body:
177
248
  # from x import y
178
249
  if isinstance(node, ast.ImportFrom):
179
- resolved = self.resolve_import(abs_file, node.module)
180
- if resolved:
181
- for name_node in node.names:
182
- local_name = name_node.asname or name_node.name
250
+ dots = '.' * node.level if node.level > 0 else ''
251
+ module_with_dots = f"{dots}{node.module}" if node.module else dots
252
+
253
+ for name_node in node.names:
254
+ local_name = name_node.asname or name_node.name
255
+
256
+ if node.module:
257
+ full_mod = f"{module_with_dots}.{name_node.name}"
258
+ else:
259
+ full_mod = f"{module_with_dots}{name_node.name}"
260
+
261
+ resolved = self.resolve_import(abs_file, full_mod)
262
+ if not resolved:
263
+ resolved = self.resolve_import(abs_file, module_with_dots)
264
+
265
+ if resolved:
183
266
  import_map[local_name] = resolved
184
267
 
185
268
  # import x
@@ -221,36 +304,46 @@ class RouteSpecExtractor:
221
304
  if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
222
305
  call = node.value
223
306
  if isinstance(call.func, ast.Attribute) and call.func.attr == 'include_router':
224
- # First arg is the router variable
225
- if len(call.args) >= 1 and isinstance(call.args[0], ast.Name):
226
- router_name = call.args[0].id
227
- mount_prefix = ""
228
- for kw in call.keywords:
229
- if kw.arg == 'prefix':
230
- val = self.get_lit_value(kw.value)
231
- if val is not None:
232
- mount_prefix = val
233
-
234
- resolved_file = import_map.get(router_name)
235
- # Reconstruct cumulative prefix
236
- # parent prefix + mount prefix
237
- cum_prefix = (prefix + mount_prefix).replace('//', '/')
238
- if resolved_file:
239
- self.mounts.append((cum_prefix, resolved_file, router_name))
240
- else:
241
- # If it's a locally defined router
242
- if router_name in router_vars:
243
- local_r_prefix = router_prefixes.get(router_name, "")
244
- self.mounts.append((cum_prefix + local_r_prefix, abs_file, router_name))
307
+ # First arg is the router variable (can be ast.Name or ast.Attribute like users.router)
308
+ if len(call.args) >= 1:
309
+ arg0 = call.args[0]
310
+ router_name = None
311
+ is_attribute = False
312
+ if isinstance(arg0, ast.Name):
313
+ router_name = arg0.id
314
+ elif isinstance(arg0, ast.Attribute) and isinstance(arg0.value, ast.Name):
315
+ router_name = arg0.value.id
316
+ is_attribute = True
317
+
318
+ if router_name:
319
+ mount_prefix = ""
320
+ for kw in call.keywords:
321
+ if kw.arg == 'prefix':
322
+ val = self.get_lit_value(kw.value)
323
+ if val is not None:
324
+ mount_prefix = val
325
+
326
+ resolved_file = import_map.get(router_name)
327
+ # Reconstruct cumulative prefix
328
+ # parent prefix + mount prefix
329
+ cum_prefix = (prefix + mount_prefix).replace('//', '/')
330
+ if resolved_file:
331
+ # If it was users.router, we mount the resolved file with the module's router name
332
+ self.mounts.append((cum_prefix, resolved_file, router_name))
245
333
  else:
246
- self.skipped.append({
247
- 'reason': f"Router '{router_name}' source file not resolved",
248
- 'file': self.get_rel_path(abs_file),
249
- 'line': node.lineno
250
- })
334
+ # If it's a locally defined router
335
+ if not is_attribute and router_name in router_vars:
336
+ local_r_prefix = router_prefixes.get(router_name, "")
337
+ self.mounts.append((cum_prefix + local_r_prefix, abs_file, router_name))
338
+ else:
339
+ self.skipped.append({
340
+ 'reason': f"Router '{router_name}' source file not resolved",
341
+ 'file': self.get_rel_path(abs_file),
342
+ 'line': node.lineno
343
+ })
251
344
 
252
345
  # FunctionDef decorated with app/router decorators
253
- elif isinstance(node, ast.FunctionDef):
346
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
254
347
  for dec in node.decorator_list:
255
348
  if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
256
349
  obj_node = dec.func.value
@@ -283,7 +376,7 @@ class RouteSpecExtractor:
283
376
  full_path = '/' + full_path
284
377
 
285
378
  # Anti-loop skip
286
- if full_path.startswith('/mcp'):
379
+ if full_path == '/mcp' or full_path.startswith('/mcp/'):
287
380
  self.skipped.append({
288
381
  'reason': f"self-referential path {full_path} blocked",
289
382
  'file': self.get_rel_path(abs_file),
@@ -327,6 +420,10 @@ class RouteSpecExtractor:
327
420
 
328
421
  # Check if it has a default value
329
422
  has_default = idx >= defaults_start_idx
423
+ if has_default:
424
+ default_val = node.args.defaults[idx - defaults_start_idx]
425
+ if self._is_depends(default_val):
426
+ continue
330
427
 
331
428
  # Parse type annotation
332
429
  arg_type, type_req = self.parse_type_annotation(arg.annotation)
@@ -403,6 +500,9 @@ class RouteSpecExtractor:
403
500
  self.entry_app_vars = list(app_vars)
404
501
 
405
502
  def run(self):
503
+ # Pre-load Pydantic models from all imported files
504
+ self.preload_models()
505
+
406
506
  # 1st pass: parse entry file
407
507
  self.parse_file(self.entry_file, '', 0)
408
508
 
@@ -44,7 +44,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
44
44
  const disabled = () => Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
45
45
 
46
46
  const server = new Server(
47
- { name: `sparda-${path.basename(cwd)}`, version: '0.4.0' },
47
+ { name: `sparda-${path.basename(cwd)}`, version: '0.5.0' },
48
48
  { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, logging: {} } },
49
49
  );
50
50
 
@@ -229,6 +229,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
229
229
  lifetime: { antibodyHits, tokensAvoidedEst: antibodyHits * DIAGNOSIS_TOKENS },
230
230
  },
231
231
  },
232
+ sparding: manifest.sparding ?? {},
232
233
  labs: { recordSequences: Boolean(recorder), compositeTools: [...composites.keys()], circuits: manifest.labs?.circuits ?? {} },
233
234
  hint: 'runtime.quarantine lists tools temporarily blocked by the immune system (503 until cooldown). immuneMemory maps failure signatures (source|tool|status) to cached diagnoses — same failure later costs zero tokens. recycling measures how much compute/intelligence was served from SPARDA\'s own memory instead of being paid again. runtime.purity classifies each route by observation: pure = same args keep returning the same response (recyclable), volatile = it changed, erasing = writes. labs.circuits are observed call sequences where one tool\'s output fed the next one\'s input.',
234
235
  }, null, 2));
@@ -265,15 +266,39 @@ export async function startStdioBridge({ cwd, portOverride }) {
265
266
  },
266
267
  }).catch(() => null);
267
268
  if (!answer || answer.action !== 'accept' || answer.content?.confirm !== true) {
269
+ const mockProof = {
270
+ version: 'sparding-proof/v0.1',
271
+ risk: 'blocked',
272
+ decision: 'block',
273
+ reasons: ['Write declined by user'],
274
+ };
275
+ recordSparding(manifestPath, manifest, name, mockProof);
268
276
  return { content: [{ type: 'text', text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.` }], isError: true };
269
277
  }
270
278
  }
271
279
 
272
280
  const payload = await invoke(base, key, name, args);
273
281
  if (payload === null) {
282
+ const mockProof = {
283
+ version: 'sparding-proof/v0.1',
284
+ risk: 'blocked',
285
+ decision: 'block',
286
+ reasons: ['Host app error or unreachable'],
287
+ };
288
+ recordSparding(manifestPath, manifest, name, mockProof);
274
289
  return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
275
290
  }
276
291
 
292
+ const proofForRecord = payload.spardingProof ? { ...payload.spardingProof } : { version: 'sparding-proof/v0.1', decision: 'allow', risk: 'low', reasons: [] };
293
+ if (payload.upstreamStatus !== undefined && payload.upstreamStatus >= 400) {
294
+ proofForRecord.isExecutionError = true;
295
+ proofForRecord.reasons = [...(proofForRecord.reasons || []), `upstream error status ${payload.upstreamStatus}`];
296
+ } else if (payload.error) {
297
+ proofForRecord.isExecutionError = true;
298
+ proofForRecord.reasons = [...(proofForRecord.reasons || []), `invocation error: ${payload.error}`];
299
+ }
300
+ recordSparding(manifestPath, manifest, name, proofForRecord);
301
+
277
302
  // condenser tap (Labs): only AI-driven calls that succeeded feed the current —
278
303
  // internal read-backs and failures are not workflow steps
279
304
  if (recorder && payload.upstreamStatus !== undefined && payload.upstreamStatus < 400) {
@@ -415,6 +440,66 @@ function persistImmune(manifestPath, manifest) {
415
440
  } catch { /* disk briefly unavailable — the antibody stays in memory */ }
416
441
  }
417
442
 
443
+ function recordSparding(manifestPath, manifest, tool, proof) {
444
+ try {
445
+ manifest.sparding ??= { version: 1, policies: {}, events: [], failures: {}, toolFingerprints: {} };
446
+ manifest.sparding.events ??= [];
447
+ manifest.sparding.failures ??= {};
448
+
449
+ const event = {
450
+ ts: new Date().toISOString(),
451
+ tool,
452
+ decision: proof.decision,
453
+ risk: proof.risk,
454
+ reasons: proof.reasons || [],
455
+ };
456
+ manifest.sparding.events.push(event);
457
+ if (manifest.sparding.events.length > 100) {
458
+ manifest.sparding.events.shift();
459
+ }
460
+
461
+ const isBlock = proof.decision === 'block';
462
+ const isError = proof.isExecutionError;
463
+ if (isBlock || isError) {
464
+ const reasonCode = proof.reasons && proof.reasons[0] ? proof.reasons[0].replace(/\s+/g, '_').toLowerCase() : 'unknown_error';
465
+ const sig = `${tool}|${reasonCode}`;
466
+ const prevFail = manifest.sparding.failures[sig] || { count: 0, lesson: '' };
467
+
468
+ let lesson = `Execution failed for tool: ${tool}.`;
469
+ if (reasonCode.includes('quarantined')) {
470
+ lesson = `Tool ${tool} was quarantined due to repeated failures.`;
471
+ } else if (reasonCode.includes('disabled')) {
472
+ lesson = `Tool ${tool} is disabled by write-safety policies.`;
473
+ } else if (reasonCode.includes('missing_path_param') || reasonCode.includes('missing_path')) {
474
+ lesson = `Client omitted a required path parameter on route ${tool}.`;
475
+ } else if (reasonCode.includes('declined')) {
476
+ lesson = `Human user declined confirmation for write tool ${tool}.`;
477
+ } else if (reasonCode.includes('policy_blocks')) {
478
+ lesson = `Security policies blocked the execution of ${tool}.`;
479
+ } else if (reasonCode.includes('upstream')) {
480
+ lesson = `Host application returned an error status code on route ${tool}.`;
481
+ }
482
+
483
+ manifest.sparding.failures[sig] = {
484
+ count: prevFail.count + 1,
485
+ lastSeen: new Date().toISOString(),
486
+ lesson,
487
+ };
488
+ }
489
+ persistSparding(manifestPath, manifest);
490
+ } catch (err) {
491
+ console.error(`[sparda] failed to record sparding proof: ${err.message}`);
492
+ }
493
+ }
494
+
495
+ function persistSparding(manifestPath, manifest) {
496
+ try {
497
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
498
+ onDisk.sparding = manifest.sparding;
499
+ fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
500
+ } catch { /* disk briefly unavailable */ }
501
+ }
502
+
418
503
  function persistLabs(manifestPath, manifest) {
419
504
  try {
420
505
  const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
@@ -5,6 +5,7 @@
5
5
  __IMPORT_LINE__
6
6
 
7
7
  const SPARDA_TOOLS = __TOOLS_JSON__;
8
+ const SPARDA_POLICIES = __SPARDING_POLICIES__;
8
9
 
9
10
  const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
10
11
  const SPARDA_PORT = __PORT__;
@@ -61,6 +62,134 @@ function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, m
61
62
  // observe-only: uncaughtExceptionMonitor never changes the host app's crash behavior
62
63
  process.on('uncaughtExceptionMonitor', (err__ANY_TYPE__) => spardaEvent('process', null, null, err && err.message ? err.message : err));
63
64
 
65
+ function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
66
+ const checks = {
67
+ knownTool: spec !== undefined,
68
+ enabled: spec ? spec.enabled : false,
69
+ loopSafe: spec ? !spec.path.startsWith('/mcp') : false,
70
+ methodClass: spec ? (spec.method === 'GET' ? 'read' : 'write') : 'read',
71
+ pathParamsPresent: true,
72
+ hasBodyForWrite: true,
73
+ reversibleHint: false,
74
+ quarantineSafe: true,
75
+ };
76
+
77
+ const reasons = [];
78
+
79
+ if (!checks.knownTool) {
80
+ reasons.push('unknown tool');
81
+ return {
82
+ version: 'sparding-proof/v0.1',
83
+ risk: 'blocked',
84
+ decision: 'block',
85
+ reasons,
86
+ checks,
87
+ };
88
+ }
89
+
90
+ if (!checks.enabled) {
91
+ reasons.push('tool disabled (write-safety)');
92
+ }
93
+ if (!checks.loopSafe) {
94
+ reasons.push('self-referential tool blocked (loop protection)');
95
+ }
96
+
97
+ const quarantined = SPARDA_QUARANTINE[tool];
98
+ if (quarantined) {
99
+ if (Date.now() < quarantined.until) {
100
+ checks.quarantineSafe = false;
101
+ reasons.push(`tool quarantined (immune system): ${quarantined.reason}`);
102
+ } else {
103
+ delete SPARDA_QUARANTINE[tool];
104
+ if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
105
+ }
106
+ }
107
+
108
+ for (const name of spec.pathParams ?? []) {
109
+ if (args[name] === undefined) {
110
+ checks.pathParamsPresent = false;
111
+ reasons.push(`missing path param: ${name}`);
112
+ }
113
+ }
114
+
115
+ if (checks.methodClass === 'write') {
116
+ if (args.body === undefined) {
117
+ checks.hasBodyForWrite = false;
118
+ }
119
+ }
120
+
121
+ if (spec.method === 'GET') {
122
+ checks.reversibleHint = true;
123
+ } else {
124
+ checks.reversibleHint = Object.values(SPARDA_TOOLS).some((t__ANY_TYPE__) => t.method === 'GET' && t.path === spec.path);
125
+ }
126
+
127
+ let risk = 'low';
128
+ let decision = 'allow';
129
+
130
+ if (!checks.enabled || !checks.loopSafe || !checks.quarantineSafe || !checks.pathParamsPresent) {
131
+ decision = 'block';
132
+ risk = 'blocked';
133
+ return {
134
+ version: 'sparding-proof/v0.1',
135
+ risk,
136
+ decision,
137
+ reasons,
138
+ checks,
139
+ };
140
+ }
141
+
142
+ const policies = SPARDA_POLICIES ?? {};
143
+ if (checks.methodClass === 'read') {
144
+ const readPolicy = policies.reads ?? 'allow';
145
+ if (readPolicy === 'block') {
146
+ decision = 'block';
147
+ risk = 'blocked';
148
+ reasons.push('read policy blocks execution');
149
+ } else if (readPolicy === 'require_human') {
150
+ decision = 'require_human';
151
+ risk = 'medium';
152
+ reasons.push('read policy requires human confirmation');
153
+ }
154
+ } else {
155
+ const isDelete = spec.method === 'DELETE';
156
+ const deletePolicy = policies.deletes ?? 'block';
157
+ const writePolicy = policies.writes ?? 'require_human';
158
+
159
+ if (isDelete && deletePolicy === 'block') {
160
+ decision = 'block';
161
+ risk = 'blocked';
162
+ reasons.push('delete policy blocks execution');
163
+ } else if (isDelete && deletePolicy === 'require_human') {
164
+ decision = 'require_human';
165
+ risk = 'high';
166
+ reasons.push('delete operation requires human confirmation');
167
+ } else if (isDelete && deletePolicy === 'allow') {
168
+ decision = 'allow';
169
+ risk = 'low';
170
+ } else if (writePolicy === 'block') {
171
+ decision = 'block';
172
+ risk = 'blocked';
173
+ reasons.push('write policy blocks execution');
174
+ } else if (writePolicy === 'require_human') {
175
+ decision = 'require_human';
176
+ risk = 'medium';
177
+ reasons.push('write operation requires human confirmation');
178
+ } else {
179
+ decision = 'allow';
180
+ risk = 'low';
181
+ }
182
+ }
183
+
184
+ return {
185
+ version: 'sparding-proof/v0.1',
186
+ risk,
187
+ decision,
188
+ reasons,
189
+ checks,
190
+ };
191
+ }
192
+
64
193
  __ROUTER_DECL__
65
194
 
66
195
  spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
@@ -83,27 +212,46 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
83
212
  const { tool, args = {} } = req.body ?? {};
84
213
  const t0 = Date.now();
85
214
  const spec = SPARDA_TOOLS[tool];
86
- if (!spec) return res.status(404).json({ error: `unknown tool: ${tool}` });
87
- if (!spec.enabled) {
88
- return res.status(403).json({ error: `tool disabled (write-safety): ${tool}`, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init' });
89
- }
90
- if (spec.path.startsWith('/mcp')) {
91
- return res.status(400).json({ error: 'self-referential tool blocked (loop protection)' });
92
- }
93
- const quarantined = SPARDA_QUARANTINE[tool];
94
- if (quarantined) {
95
- if (Date.now() < quarantined.until) {
96
- SPARDA_RECYCLE.servedByCircle += 1; // answered from immune memory — the doomed host call was never paid
97
- return res.status(503).json({ error: `tool quarantined (immune system): ${tool}`, reason: quarantined.reason, retryInMs: quarantined.until - Date.now() });
215
+
216
+ const proof = spardaProof(tool, spec, args);
217
+
218
+ if (proof.decision === 'block') {
219
+ let status = 400;
220
+ let error = 'bad request';
221
+ if (!proof.checks.knownTool) {
222
+ status = 404;
223
+ error = `unknown tool: ${tool}`;
224
+ } else if (!proof.checks.enabled) {
225
+ status = 403;
226
+ error = `tool disabled (write-safety): ${tool}`;
227
+ return res.status(status).json({ error, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init', spardingProof: proof });
228
+ } else if (!proof.checks.loopSafe) {
229
+ status = 400;
230
+ error = 'self-referential tool blocked (loop protection)';
231
+ } else if (!proof.checks.quarantineSafe) {
232
+ status = 503;
233
+ const quarantined = SPARDA_QUARANTINE[tool];
234
+ error = `tool quarantined (immune system): ${tool}`;
235
+ SPARDA_RECYCLE.servedByCircle += 1;
236
+ return res.status(status).json({
237
+ error,
238
+ reason: quarantined ? quarantined.reason : '',
239
+ retryInMs: quarantined ? Math.max(0, quarantined.until - Date.now()) : 0,
240
+ spardingProof: proof
241
+ });
242
+ } else {
243
+ status = 400;
244
+ error = proof.reasons[0] || 'blocked';
245
+ if (proof.reasons.some((r__ANY_TYPE__) => r.includes('policy blocks execution'))) {
246
+ status = 403;
247
+ }
98
248
  }
99
- // half-open: cooldown elapsed allow one probe; a single new 5xx re-quarantines
100
- delete SPARDA_QUARANTINE[tool];
101
- if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
249
+ return res.status(status).json({ error, spardingProof: proof });
102
250
  }
251
+
103
252
  try {
104
253
  let url = spec.path.replace(/:(\w+)/g, (_, name) => {
105
254
  const v = args[name];
106
- if (v === undefined) throw Object.assign(new Error(`missing path param: ${name}`), { status: 400 });
107
255
  return encodeURIComponent(String(v));
108
256
  });
109
257
  const query = [];
@@ -128,11 +276,11 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
128
276
  spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
129
277
  }
130
278
  if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
131
- return res.status(200).json({ upstreamStatus: upstream.status, data });
279
+ return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
132
280
  } catch (err__ANY_TYPE__) {
133
281
  spardaRecord(tool, err.status ?? 502, Date.now() - t0);
134
282
  spardaEvent('invoke', tool, err.status ?? 502, err.message);
135
- return res.status(err.status ?? 502).json({ error: err.message });
283
+ return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
136
284
  }
137
285
  });
138
286
 
@@ -17,6 +17,7 @@ import zlib
17
17
 
18
18
  # json.loads, not a Python literal: JSON true/false/null are not valid Python (E-009)
19
19
  SPARDA_TOOLS = json.loads(__TOOLS_JSON__)
20
+ SPARDA_POLICIES = json.loads(__SPARDING_POLICIES__)
20
21
  SPARDA_LOCAL_KEY = "__LOCAL_KEY__"
21
22
  SPARDA_PORT = __PORT__
22
23
 
@@ -110,6 +111,120 @@ def sparda_record(tool, status, ms):
110
111
  }
111
112
  sparda_event("immune", tool, 503, f"quarantined after {st['consecutive5xx']} consecutive 5xx (cooldown {SPARDA_QUARANTINE_MS}ms)")
112
113
 
114
+ def sparda_proof(tool, spec, args):
115
+ checks = {
116
+ "knownTool": spec is not None,
117
+ "enabled": spec.get("enabled", False) if spec else False,
118
+ "loopSafe": not spec.get("path", "").startswith("/mcp") if spec else False,
119
+ "methodClass": "read" if spec and spec.get("method") == "GET" else "write",
120
+ "pathParamsPresent": True,
121
+ "hasBodyForWrite": True,
122
+ "reversibleHint": False,
123
+ "quarantineSafe": True,
124
+ }
125
+
126
+ reasons = []
127
+
128
+ if not checks["knownTool"]:
129
+ reasons.append("unknown tool")
130
+ return {
131
+ "version": "sparding-proof/v0.1",
132
+ "risk": "blocked",
133
+ "decision": "block",
134
+ "reasons": reasons,
135
+ "checks": checks,
136
+ }
137
+
138
+ if not checks["enabled"]:
139
+ reasons.append("tool disabled (write-safety)")
140
+ if not checks["loopSafe"]:
141
+ reasons.append("self-referential tool blocked (loop protection)")
142
+
143
+ quarantined = SPARDA_QUARANTINE.get(tool)
144
+ if quarantined:
145
+ if (time.time() * 1000) < quarantined["until"]:
146
+ checks["quarantineSafe"] = False
147
+ reasons.append(f"tool quarantined (immune system): {quarantined['reason']}")
148
+ else:
149
+ del SPARDA_QUARANTINE[tool]
150
+ if tool in SPARDA_STATS:
151
+ SPARDA_STATS[tool]["consecutive5xx"] = 2
152
+
153
+ for name in spec.get("pathParams", []):
154
+ if args.get(name) is None:
155
+ checks["pathParamsPresent"] = False
156
+ reasons.append(f"missing path param: {name}")
157
+
158
+ if checks["methodClass"] == "write":
159
+ if args.get("body") is None:
160
+ checks["hasBodyForWrite"] = False
161
+
162
+ if spec.get("method") == "GET":
163
+ checks["reversibleHint"] = True
164
+ else:
165
+ checks["reversibleHint"] = any(t.get("method") == "GET" and t.get("path") == spec.get("path") for t in SPARDA_TOOLS.values())
166
+
167
+ risk = "low"
168
+ decision = "allow"
169
+
170
+ if not checks["enabled"] or not checks["loopSafe"] or not checks["quarantineSafe"] or not checks["pathParamsPresent"]:
171
+ decision = "block"
172
+ risk = "blocked"
173
+ return {
174
+ "version": "sparding-proof/v0.1",
175
+ "risk": risk,
176
+ "decision": decision,
177
+ "reasons": reasons,
178
+ "checks": checks,
179
+ }
180
+
181
+ policies = SPARDA_POLICIES or {}
182
+ if checks["methodClass"] == "read":
183
+ read_policy = policies.get("reads", "allow")
184
+ if read_policy == "block":
185
+ decision = "block"
186
+ risk = "blocked"
187
+ reasons.append("read policy blocks execution")
188
+ elif read_policy == "require_human":
189
+ decision = "require_human"
190
+ risk = "medium"
191
+ reasons.append("read policy requires human confirmation")
192
+ else:
193
+ is_delete = spec.get("method") == "DELETE"
194
+ delete_policy = policies.get("deletes", "block")
195
+ write_policy = policies.get("writes", "require_human")
196
+
197
+ if is_delete and delete_policy == "block":
198
+ decision = "block"
199
+ risk = "blocked"
200
+ reasons.append("delete policy blocks execution")
201
+ elif is_delete and delete_policy == "require_human":
202
+ decision = "require_human"
203
+ risk = "high"
204
+ reasons.append("delete operation requires human confirmation")
205
+ elif is_delete and delete_policy == "allow":
206
+ decision = "allow"
207
+ risk = "low"
208
+ elif write_policy == "block":
209
+ decision = "block"
210
+ risk = "blocked"
211
+ reasons.append("write policy blocks execution")
212
+ elif write_policy == "require_human":
213
+ decision = "require_human"
214
+ risk = "medium"
215
+ reasons.append("write operation requires human confirmation")
216
+ else:
217
+ decision = "allow"
218
+ risk = "low"
219
+
220
+ return {
221
+ "version": "sparding-proof/v0.1",
222
+ "risk": risk,
223
+ "decision": decision,
224
+ "reasons": reasons,
225
+ "checks": checks,
226
+ }
227
+
113
228
  sparda_router = APIRouter()
114
229
  executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
115
230
 
@@ -162,43 +277,51 @@ async def invoke_tool(request: Request):
162
277
  args = body.get("args", {})
163
278
 
164
279
  spec = SPARDA_TOOLS.get(tool)
165
- if not spec:
166
- return JSONResponse(status_code=404, content={"error": f"unknown tool: {tool}"})
280
+ proof = sparda_proof(tool, spec, args)
167
281
 
168
- if not spec.get("enabled"):
169
- return JSONResponse(status_code=403, content={
170
- "error": f"tool disabled (write-safety): {tool}",
171
- "hint": "Enable it in sparda.json, then re-run: npx sparda-mcp init"
172
- })
173
-
174
- path = spec.get("path")
175
- if path.startswith("/mcp"):
176
- return JSONResponse(status_code=400, content={"error": "self-referential tool blocked (loop protection)"})
177
-
178
- quarantined = SPARDA_QUARANTINE.get(tool)
179
- if quarantined:
180
- now_ms = time.time() * 1000
181
- if now_ms < quarantined["until"]:
182
- SPARDA_RECYCLE["servedByCircle"] += 1 # answered from immune memory — the doomed host call was never paid
183
- return JSONResponse(status_code=503, content={
184
- "error": f"tool quarantined (immune system): {tool}",
185
- "reason": quarantined["reason"],
186
- "retryInMs": round(quarantined["until"] - now_ms),
282
+ if proof["decision"] == "block":
283
+ status = 400
284
+ error = "bad request"
285
+ if not proof["checks"]["knownTool"]:
286
+ status = 404
287
+ error = f"unknown tool: {tool}"
288
+ elif not proof["checks"]["enabled"]:
289
+ status = 403
290
+ error = f"tool disabled (write-safety): {tool}"
291
+ return JSONResponse(status_code=status, content={
292
+ "error": error,
293
+ "hint": "Enable it in sparda.json, then re-run: npx sparda-mcp init",
294
+ "spardingProof": proof
295
+ })
296
+ elif not proof["checks"]["loopSafe"]:
297
+ status = 400
298
+ error = "self-referential tool blocked (loop protection)"
299
+ elif not proof["checks"]["quarantineSafe"]:
300
+ status = 503
301
+ quarantined = SPARDA_QUARANTINE.get(tool)
302
+ error = f"tool quarantined (immune system): {tool}"
303
+ SPARDA_RECYCLE["servedByCircle"] += 1
304
+ return JSONResponse(status_code=status, content={
305
+ "error": error,
306
+ "reason": quarantined["reason"] if quarantined else "",
307
+ "retryInMs": round(quarantined["until"] - time.time() * 1000) if quarantined else 0,
308
+ "spardingProof": proof
187
309
  })
188
- # half-open: cooldown elapsed — allow one probe; a single new 5xx re-quarantines
189
- del SPARDA_QUARANTINE[tool]
190
- if tool in SPARDA_STATS:
191
- SPARDA_STATS[tool]["consecutive5xx"] = 2
310
+ else:
311
+ status = 400
312
+ error = proof["reasons"][0] if proof["reasons"] else "blocked"
313
+ if any("policy blocks execution" in r for r in proof["reasons"]):
314
+ status = 403
315
+ return JSONResponse(status_code=status, content={"error": error, "spardingProof": proof})
192
316
 
193
317
  t0 = time.time()
194
318
  try:
195
319
  path_params = spec.get("pathParams", [])
320
+ path = spec.get("path")
196
321
 
197
322
  def repl(match):
198
323
  name = match.group(1)
199
324
  val = args.get(name)
200
- if val is None:
201
- raise HTTPException(status_code=400, detail=f"missing path param: {name}")
202
325
  return urllib.parse.quote(str(val))
203
326
 
204
327
  resolved_path = re.sub(r'\{(\w+)\}', repl, path)
@@ -241,12 +364,8 @@ async def invoke_tool(request: Request):
241
364
  sparda_observe_purity(tool, argsig, data_bytes)
242
365
  if status >= 500:
243
366
  sparda_event("invoke", tool, status, text_data[:200])
244
- return {"upstreamStatus": status, "data": data}
245
- except HTTPException as e:
246
- sparda_record(tool, e.status_code, round((time.time() - t0) * 1000))
247
- sparda_event("invoke", tool, e.status_code, e.detail)
248
- return JSONResponse(status_code=e.status_code, content={"error": e.detail})
367
+ return {"upstreamStatus": status, "data": data, "spardingProof": proof}
249
368
  except Exception as e:
250
369
  sparda_record(tool, 502, round((time.time() - t0) * 1000))
251
370
  sparda_event("invoke", tool, 502, str(e))
252
- return JSONResponse(status_code=502, content={"error": str(e)})
371
+ return JSONResponse(status_code=502, content={"error": str(e), "spardingProof": proof})