sparda-mcp 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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": {
7
7
  "sparda": "./src/index.js"
8
8
  },
9
9
  "scripts": {
10
- "test": "vitest run"
10
+ "test": "vitest run",
11
+ "test:router": "node tests/router-selftest.cjs",
12
+ "test:all": "vitest run && node tests/router-selftest.cjs"
11
13
  },
12
14
  "files": [
13
15
  "src",
14
16
  "templates",
17
+ "!templates/*.bak",
15
18
  "README.md",
16
19
  "LICENSE"
17
20
  ],
@@ -36,7 +39,7 @@
36
39
  "@modelcontextprotocol/sdk": "1.29.0"
37
40
  },
38
41
  "devDependencies": {
39
- "express": "4.21.2",
42
+ "express": "^4.21.0",
40
43
  "vitest": "^3.2.6"
41
44
  }
42
45
  }
@@ -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,54 +62,168 @@ 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
 
64
- __ROUTER_DECL__
65
-
66
- spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
67
- if (req.headers['x-sparda-key'] !== SPARDA_LOCAL_KEY) {
68
- return res.status(401).json({ error: 'unauthorized' });
69
- }
70
- next();
71
- });
65
+ // two-phase commit: a write/delete flagged require_human is NOT executed on /invoke.
66
+ // It mints a single-use nonce + a preview contract; the host route is touched only after
67
+ // an explicit /invoke/confirm replays the token. This is the gate the proof always described
68
+ // but never enforced — the difference between a receipt and a contract.
69
+ const SPARDA_PENDING__STATS_TYPE__ = {};
70
+ const SPARDA_CONFIRM_TTL_MS = Number(process.env.SPARDA_CONFIRM_TTL_MS ?? 120000);
71
+ function spardaNonce() {
72
+ return 'cfm_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
73
+ }
74
+ function spardaSweepPending() {
75
+ const now = Date.now();
76
+ for (const k of Object.keys(SPARDA_PENDING)) if (now > SPARDA_PENDING[k].expiresAt) delete SPARDA_PENDING[k];
77
+ }
72
78
 
73
- spardaRouter.get('/tools', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json(SPARDA_TOOLS));
79
+ function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
80
+ const checks = {
81
+ knownTool: spec !== undefined,
82
+ enabled: spec ? spec.enabled : false,
83
+ loopSafe: spec ? !spec.path.startsWith('/mcp') : false,
84
+ methodClass: spec ? (spec.method === 'GET' ? 'read' : 'write') : 'read',
85
+ pathParamsPresent: true,
86
+ hasBodyForWrite: true,
87
+ reversibleHint: false,
88
+ quarantineSafe: true,
89
+ };
74
90
 
75
- spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE, recycle: { servedByCircle: SPARDA_RECYCLE.servedByCircle, paidFull: SPARDA_RECYCLE.paidFull, ratePct: spardaRecycleRate() }, purity: spardaPuritySnapshot() }));
91
+ const reasons = [];
76
92
 
77
- spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
78
- const since = Number(req.query.since ?? 0) || 0;
79
- res.json({ seq: SPARDA_SEQ, events: SPARDA_EVENTS.filter((e__ANY_TYPE__) => e.seq > since) });
80
- });
93
+ if (!checks.knownTool) {
94
+ reasons.push('unknown tool');
95
+ return {
96
+ version: 'sparding-proof/v0.1',
97
+ risk: 'blocked',
98
+ decision: 'block',
99
+ reasons,
100
+ checks,
101
+ };
102
+ }
81
103
 
82
- spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE__) => {
83
- const { tool, args = {} } = req.body ?? {};
84
- const t0 = Date.now();
85
- 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' });
104
+ if (!checks.enabled) {
105
+ reasons.push('tool disabled (write-safety)');
89
106
  }
90
- if (spec.path.startsWith('/mcp')) {
91
- return res.status(400).json({ error: 'self-referential tool blocked (loop protection)' });
107
+ if (!checks.loopSafe) {
108
+ reasons.push('self-referential tool blocked (loop protection)');
92
109
  }
110
+
93
111
  const quarantined = SPARDA_QUARANTINE[tool];
94
112
  if (quarantined) {
95
113
  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() });
114
+ checks.quarantineSafe = false;
115
+ reasons.push(`tool quarantined (immune system): ${quarantined.reason}`);
116
+ } else {
117
+ delete SPARDA_QUARANTINE[tool];
118
+ if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
98
119
  }
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;
102
120
  }
121
+
122
+ for (const name of spec.pathParams ?? []) {
123
+ if (args[name] === undefined) {
124
+ checks.pathParamsPresent = false;
125
+ reasons.push(`missing path param: ${name}`);
126
+ }
127
+ }
128
+
129
+ if (checks.methodClass === 'write') {
130
+ if (args.body === undefined) {
131
+ checks.hasBodyForWrite = false;
132
+ }
133
+ }
134
+
135
+ if (spec.method === 'GET') {
136
+ checks.reversibleHint = true;
137
+ } else {
138
+ checks.reversibleHint = Object.values(SPARDA_TOOLS).some((t__ANY_TYPE__) => t.method === 'GET' && t.path === spec.path);
139
+ }
140
+
141
+ let risk = 'low';
142
+ let decision = 'allow';
143
+
144
+ if (!checks.enabled || !checks.loopSafe || !checks.quarantineSafe || !checks.pathParamsPresent) {
145
+ decision = 'block';
146
+ risk = 'blocked';
147
+ return {
148
+ version: 'sparding-proof/v0.1',
149
+ risk,
150
+ decision,
151
+ reasons,
152
+ checks,
153
+ };
154
+ }
155
+
156
+ const policies = SPARDA_POLICIES ?? {};
157
+ if (checks.methodClass === 'read') {
158
+ const readPolicy = policies.reads ?? 'allow';
159
+ if (readPolicy === 'block') {
160
+ decision = 'block';
161
+ risk = 'blocked';
162
+ reasons.push('read policy blocks execution');
163
+ } else if (readPolicy === 'require_human') {
164
+ decision = 'require_human';
165
+ risk = 'medium';
166
+ reasons.push('read policy requires human confirmation');
167
+ }
168
+ } else {
169
+ const isDelete = spec.method === 'DELETE';
170
+ const deletePolicy = policies.deletes ?? 'block';
171
+ const writePolicy = policies.writes ?? 'require_human';
172
+
173
+ if (isDelete && deletePolicy === 'block') {
174
+ decision = 'block';
175
+ risk = 'blocked';
176
+ reasons.push('delete policy blocks execution');
177
+ } else if (isDelete && deletePolicy === 'require_human') {
178
+ decision = 'require_human';
179
+ risk = 'high';
180
+ reasons.push('delete operation requires human confirmation');
181
+ } else if (isDelete && deletePolicy === 'allow') {
182
+ decision = 'allow';
183
+ risk = 'low';
184
+ } else if (writePolicy === 'block') {
185
+ decision = 'block';
186
+ risk = 'blocked';
187
+ reasons.push('write policy blocks execution');
188
+ } else if (writePolicy === 'require_human') {
189
+ decision = 'require_human';
190
+ risk = 'medium';
191
+ reasons.push('write operation requires human confirmation');
192
+ } else {
193
+ decision = 'allow';
194
+ risk = 'low';
195
+ }
196
+ }
197
+
198
+ return {
199
+ version: 'sparding-proof/v0.1',
200
+ risk,
201
+ decision,
202
+ reasons,
203
+ checks,
204
+ };
205
+ }
206
+
207
+ // captured once so the JSON-middleware placeholder appears a single time in the template
208
+ // (the generator's replace is non-global).
209
+ const SPARDA_JSON_MW__ANY_TYPE__ = __JSON_MW__;
210
+ // SPARDA owns its own endpoints' body parsing AND the parse error: a malformed JSON body
211
+ // returns JSON 400 locally instead of bubbling up to Express' HTML error page (stack leak).
212
+ function SPARDA_JSON(req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) {
213
+ SPARDA_JSON_MW(req, res, (err__ANY_TYPE__) => {
214
+ if (err) { spardaEvent('router', null, 400, 'invalid JSON body'); return res.status(400).json({ error: 'invalid JSON body' }); }
215
+ next();
216
+ });
217
+ }
218
+
219
+ // the actual host-route call, shared by the allow-path and the confirm-path so a
220
+ // confirmed write runs through the exact same execution + observation as a direct one.
221
+ async function spardaExecute(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__, proof__ANY_TYPE__, t0__ANY_TYPE__, res__RES_TYPE__) {
103
222
  try {
104
- let url = spec.path.replace(/:(\w+)/g, (_, name) => {
105
- const v = args[name];
106
- if (v === undefined) throw Object.assign(new Error(`missing path param: ${name}`), { status: 400 });
107
- return encodeURIComponent(String(v));
108
- });
223
+ let url = spec.path.replace(/:(\w+)/g, (_, name) => encodeURIComponent(String(args[name])));
109
224
  const query = [];
110
225
  for (const [k, v] of Object.entries(args)) {
111
- if (k === 'body' || spec.pathParams.includes(k) || v === undefined) continue;
226
+ if (k === 'body' || (spec.pathParams ?? []).includes(k) || v === undefined) continue;
112
227
  query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
113
228
  }
114
229
  if (query.length) url += `?${query.join('&')}`;
@@ -128,14 +243,144 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
128
243
  spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
129
244
  }
130
245
  if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
131
- return res.status(200).json({ upstreamStatus: upstream.status, data });
246
+ return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
132
247
  } catch (err__ANY_TYPE__) {
133
248
  spardaRecord(tool, err.status ?? 502, Date.now() - t0);
134
249
  spardaEvent('invoke', tool, err.status ?? 502, err.message);
135
- return res.status(err.status ?? 502).json({ error: err.message });
250
+ return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
136
251
  }
252
+ }
253
+
254
+ __ROUTER_DECL__
255
+
256
+ spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
257
+ if (req.headers['x-sparda-key'] !== SPARDA_LOCAL_KEY) {
258
+ return res.status(401).json({ error: 'unauthorized' });
259
+ }
260
+ next();
261
+ });
262
+
263
+ spardaRouter.get('/tools', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json(SPARDA_TOOLS));
264
+
265
+ spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE, recycle: { servedByCircle: SPARDA_RECYCLE.servedByCircle, paidFull: SPARDA_RECYCLE.paidFull, ratePct: spardaRecycleRate() }, purity: spardaPuritySnapshot() }));
266
+
267
+ spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
268
+ const since = Number(req.query.since ?? 0) || 0;
269
+ res.json({ seq: SPARDA_SEQ, events: SPARDA_EVENTS.filter((e__ANY_TYPE__) => e.seq > since) });
137
270
  });
138
271
 
272
+ spardaRouter.post('/invoke', SPARDA_JSON, async (req__REQ_TYPE__, res__RES_TYPE__) => {
273
+ const t0 = Date.now();
274
+ const reqBody = req.body ?? {};
275
+ const tool = reqBody.tool;
276
+ // type confusion guard: the old `args = {}` default only caught `undefined`. A `null`
277
+ // (or array/scalar) slipped through and crashed spardaProof on null[name] → Express HTML
278
+ // 500 with a full stack trace. We reject non-objects with clean JSON instead.
279
+ if (reqBody.args !== undefined && (reqBody.args === null || typeof reqBody.args !== 'object' || Array.isArray(reqBody.args))) {
280
+ 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 });
281
+ }
282
+ const args = reqBody.args ?? {};
283
+ const spec = SPARDA_TOOLS[tool];
284
+
285
+ const proof = spardaProof(tool, spec, args);
286
+
287
+ if (proof.decision === 'block') {
288
+ let status = 400;
289
+ let error = 'bad request';
290
+ if (!proof.checks.knownTool) {
291
+ status = 404;
292
+ error = `unknown tool: ${tool}`;
293
+ } else if (!proof.checks.enabled) {
294
+ status = 403;
295
+ error = `tool disabled (write-safety): ${tool}`;
296
+ return res.status(status).json({ error, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init', spardingProof: proof });
297
+ } else if (!proof.checks.loopSafe) {
298
+ status = 400;
299
+ error = 'self-referential tool blocked (loop protection)';
300
+ } else if (!proof.checks.quarantineSafe) {
301
+ status = 503;
302
+ const quarantined = SPARDA_QUARANTINE[tool];
303
+ error = `tool quarantined (immune system): ${tool}`;
304
+ SPARDA_RECYCLE.servedByCircle += 1;
305
+ return res.status(status).json({
306
+ error,
307
+ reason: quarantined ? quarantined.reason : '',
308
+ retryInMs: quarantined ? Math.max(0, quarantined.until - Date.now()) : 0,
309
+ spardingProof: proof
310
+ });
311
+ } else {
312
+ status = 400;
313
+ error = proof.reasons[0] || 'blocked';
314
+ if (proof.reasons.some((r__ANY_TYPE__) => r.includes('policy blocks execution'))) {
315
+ status = 403;
316
+ }
317
+ }
318
+ return res.status(status).json({ error, spardingProof: proof });
319
+ }
320
+
321
+ if (proof.decision === 'require_human') {
322
+ // THE GATE THE PROOF ALWAYS LACKED.
323
+ // v0.5.0 had no branch here, so a require_human decision fell straight through to the
324
+ // host route below: the write happened, THEN the proof said "confirmation required".
325
+ // We stop here. The host route is not touched. We mint a single-use nonce and a preview
326
+ // the LLM reads in its tool-result, and only /invoke/confirm replays it.
327
+ spardaSweepPending();
328
+ const confirm = spardaNonce();
329
+ SPARDA_PENDING[confirm] = { tool, args, expiresAt: Date.now() + SPARDA_CONFIRM_TTL_MS, createdAt: new Date().toISOString() };
330
+ let siblingName = null;
331
+ for (const [n, t] of Object.entries(SPARDA_TOOLS)) { if (t.method === 'GET' && t.path === spec.path) { siblingName = n; break; } }
332
+ spardaEvent('confirm', tool, 202, `gated, awaiting confirmation (risk: ${proof.risk})`);
333
+ return res.status(202).json({
334
+ status: 'awaiting_confirmation',
335
+ confirm,
336
+ expiresInMs: SPARDA_CONFIRM_TTL_MS,
337
+ preview: {
338
+ tool,
339
+ method: spec.method,
340
+ path: spec.path,
341
+ willSendBody: spec.method !== 'GET' && args.body !== undefined,
342
+ reversibleHint: proof.checks.reversibleHint,
343
+ inspectFirst: siblingName ? { tool: siblingName, why: 'GET on the same path — read current state before committing this write' } : null,
344
+ },
345
+ // this string is what the LLM reads: the security decision IS the instruction.
346
+ 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.`,
347
+ spardingProof: proof,
348
+ });
349
+ }
350
+
351
+ // proof.decision === 'allow' — exercise the host route
352
+ return spardaExecute(tool, spec, args, proof, t0, res);
353
+ });
354
+
355
+ spardaRouter.post('/invoke/confirm', SPARDA_JSON, async (req__REQ_TYPE__, res__RES_TYPE__) => {
356
+ const t0 = Date.now();
357
+ const confirm = (req.body ?? {}).confirm;
358
+ if (typeof confirm !== 'string' || !confirm) {
359
+ return res.status(400).json({ error: 'missing confirm token (expected { confirm: "..." })' });
360
+ }
361
+ spardaSweepPending();
362
+ const pending = SPARDA_PENDING[confirm];
363
+ if (pending) delete SPARDA_PENDING[confirm]; // consume first — a token can never replay
364
+ if (!pending) {
365
+ return res.status(409).json({ error: 'unknown or expired confirmation token', hint: 're-issue the write via /invoke to mint a fresh token' });
366
+ }
367
+ if (Date.now() > pending.expiresAt) {
368
+ return res.status(409).json({ error: 'confirmation token expired', hint: 're-issue the write via /invoke' });
369
+ }
370
+ const spec = SPARDA_TOOLS[pending.tool];
371
+ // re-judge at commit time: the tool may have been quarantined or disabled since minting
372
+ const proof = spardaProof(pending.tool, spec, pending.args);
373
+ if (proof.decision === 'block') {
374
+ return res.status(403).json({ error: 'confirmation no longer valid', reason: proof.reasons[0] || 'blocked', spardingProof: proof });
375
+ }
376
+ spardaEvent('confirm', pending.tool, 200, 'confirmed -> executing');
377
+ return spardaExecute(pending.tool, spec, pending.args, proof, t0, res);
378
+ });
379
+
380
+ // verb safety: any non-POST on the invoke endpoints returns JSON 405 (was bare Express HTML — bug #5)
381
+ spardaRouter.all('/invoke', (req__REQ_TYPE__, res__RES_TYPE__) => res.status(405).json({ error: 'method not allowed', allow: 'POST' }));
382
+ spardaRouter.all('/invoke/confirm', (req__REQ_TYPE__, res__RES_TYPE__) => res.status(405).json({ error: 'method not allowed', allow: 'POST' }));
383
+
139
384
  function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
140
385
  const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
141
386
  // innate immunity: latency far beyond the learned baseline is an antigen
@@ -156,3 +401,17 @@ function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
156
401
  spardaEvent('immune', tool, 503, `quarantined after ${st.consecutive5xx} consecutive 5xx (cooldown ${SPARDA_QUARANTINE_MS}ms)`);
157
402
  }
158
403
  }
404
+
405
+ // anything unmatched under the router → JSON 404, never bare Express HTML
406
+ spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__) => res.status(404).json({ error: 'not found', path: req.path }));
407
+
408
+ // error envelope (must stay last). body-parser throws SyntaxError on malformed JSON, and
409
+ // anything thrown downstream used to fall through to Express' HTML error page — leaking the
410
+ // internal stack trace. Every response stays JSON; the stack stays server-side, correlated
411
+ // to /events by errorId.
412
+ spardaRouter.use((err__ANY_TYPE__, req__REQ_TYPE__, res__RES_TYPE__, _next__NEXT_TYPE__) => {
413
+ const errorId = 'err_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
414
+ spardaEvent('router', null, (err && err.status) || 500, `[${errorId}] ${err && err.message ? err.message : err}`);
415
+ const status = err && (err.type === 'entity.parse.failed' || err instanceof SyntaxError) ? 400 : (err && err.status) || 500;
416
+ res.status(status).json({ error: status === 400 ? 'invalid JSON body' : 'internal error', errorId });
417
+ });
@@ -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})