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 +1 -1
- package/src/generator/express.js +25 -1
- package/src/generator/fastapi.js +25 -1
- package/src/generator/manifest.js +18 -3
- package/src/parser/express.js +1 -1
- package/src/parser/fastapi_extract.py +132 -32
- package/src/server/stdio.js +86 -1
- package/templates/express-router.txt +166 -18
- package/templates/fastapi-router.txt +152 -33
package/package.json
CHANGED
package/src/generator/express.js
CHANGED
|
@@ -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
|
|
package/src/generator/fastapi.js
CHANGED
|
@@ -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)
|
|
8
|
-
// state (opt-in flags + observed circuits)
|
|
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
|
+
}
|
package/src/parser/express.js
CHANGED
|
@@ -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
|
-
|
|
180
|
-
if
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
package/src/server/stdio.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
return JSONResponse(status_code=404, content={"error": f"unknown tool: {tool}"})
|
|
280
|
+
proof = sparda_proof(tool, spec, args)
|
|
167
281
|
|
|
168
|
-
if
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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})
|