sparda-mcp 0.5.1 → 0.5.3
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 +9 -1
- package/src/commands/init.js +31 -1
- package/src/generator/express.js +1 -6
- package/src/generator/fastapi.js +5 -7
- package/src/index.js +2 -0
- package/src/probe/express-shim-esm.mjs +27 -0
- package/src/probe/express-shim.cjs +208 -0
- package/src/probe/fastapi-probe.py +146 -0
- package/src/probe/integrate.js +104 -0
- package/src/probe/probe.js +243 -0
- package/src/probe/reconcile.js +160 -0
- package/src/server/condenser.js +3 -2
- package/src/server/confirmation.js +165 -0
- package/src/server/context-carrier.js +322 -0
- package/src/server/crystallize.js +1 -1
- package/src/server/engine.js +579 -0
- package/src/server/idle.js +1 -1
- package/src/server/persistence.js +269 -0
- package/src/server/stdio.js +182 -34
- package/templates/express-router.txt +60 -0
- package/templates/fastapi-router.txt +238 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPARDA — Runtime Route Probe (orchestrator) v2
|
|
3
|
+
*
|
|
4
|
+
* Changes from v1:
|
|
5
|
+
* - checkCommand: 1500 ms kill timeout (§A.6 / ANALYSE §4: Windows MS Store hang)
|
|
6
|
+
* - FastAPI availability detection: spawnSync cross-platform, no shell string (§A.5.4)
|
|
7
|
+
* - Everything else kept verbatim (§B)
|
|
8
|
+
*
|
|
9
|
+
* Exports: async probeRoutes({ framework, entryFile, projectRoot, timeoutMs })
|
|
10
|
+
*
|
|
11
|
+
* ESM, Node ≥ 18. Zero new dependencies.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { fork, spawn, spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { dirname, resolve, extname } from 'node:path';
|
|
17
|
+
import { platform } from 'node:process';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
const SHIM_CJS = resolve(__dirname, 'express-shim.cjs');
|
|
22
|
+
const SHIM_ESM = resolve(__dirname, 'express-shim-esm.mjs');
|
|
23
|
+
const PY_PROBE = resolve(__dirname, 'fastapi-probe.py');
|
|
24
|
+
|
|
25
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
26
|
+
|
|
27
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export async function probeRoutes({ framework, entryFile, projectRoot, timeoutMs = 8000 }) {
|
|
30
|
+
if (framework === 'express') return probeExpress({ entryFile, projectRoot, timeoutMs });
|
|
31
|
+
if (framework === 'fastapi') return probeFastAPI({ entryFile, projectRoot, timeoutMs });
|
|
32
|
+
throw Object.assign(
|
|
33
|
+
new Error(`Unknown framework: ${framework}`),
|
|
34
|
+
{ code: 'USER', hint: 'framework must be "express" or "fastapi"' }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Express probe ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
|
|
41
|
+
const ext = extname(entryFile);
|
|
42
|
+
|
|
43
|
+
if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
'[sparda] --probe skipped: .ts entry needs a runtime loader; static discovery still applied.\n'
|
|
46
|
+
);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isEsm = ext === '.mjs';
|
|
51
|
+
const shimFlag = isEsm ? ['--import', SHIM_ESM] : ['--require', SHIM_CJS];
|
|
52
|
+
|
|
53
|
+
const routes = [];
|
|
54
|
+
let child;
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve_) => {
|
|
57
|
+
let settled = false;
|
|
58
|
+
let killTimer;
|
|
59
|
+
|
|
60
|
+
function settle(result) {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(killTimer);
|
|
64
|
+
try { child && child.kill('SIGKILL'); } catch {}
|
|
65
|
+
resolve_(result);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
killTimer = setTimeout(() => {
|
|
69
|
+
process.stderr.write('[sparda] --probe: timeout waiting for Express routes; using static floor.\n');
|
|
70
|
+
settle(routes);
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
child = fork(entryFile, [], {
|
|
75
|
+
cwd: projectRoot,
|
|
76
|
+
execArgv: shimFlag,
|
|
77
|
+
silent: true,
|
|
78
|
+
env: { ...process.env, SPARDA_PROBE: '1' },
|
|
79
|
+
});
|
|
80
|
+
} catch (spawnErr) {
|
|
81
|
+
process.stderr.write(`[sparda] --probe: failed to spawn child: ${spawnErr.message}\n`);
|
|
82
|
+
clearTimeout(killTimer);
|
|
83
|
+
resolve_([]);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
child.stderr?.on('data', () => {});
|
|
88
|
+
|
|
89
|
+
child.on('message', (msg) => {
|
|
90
|
+
if (!msg || typeof msg !== 'object') return;
|
|
91
|
+
if (msg.type === '__done__') { settle(routes); return; }
|
|
92
|
+
if (msg.type === 'route') {
|
|
93
|
+
const method = normalizeMethod(msg.method);
|
|
94
|
+
const path = normalizePath(msg.path);
|
|
95
|
+
routes.push({
|
|
96
|
+
method,
|
|
97
|
+
path,
|
|
98
|
+
pathParams: extractPathParams(path),
|
|
99
|
+
source: 'dynamic',
|
|
100
|
+
writeClass: WRITE_METHODS.has(method) ? 'write' : 'read',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
child.on('error', (err) => {
|
|
106
|
+
process.stderr.write(`[sparda] --probe: child error: ${err.message}\n`);
|
|
107
|
+
settle(routes);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
child.on('exit', () => settle(routes));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── FastAPI probe ─────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async function probeFastAPI({ entryFile, projectRoot, timeoutMs }) {
|
|
117
|
+
const python = await resolvePython();
|
|
118
|
+
if (!python) {
|
|
119
|
+
process.stderr.write('[sparda] --probe: python3/python not found; static discovery still applied.\n');
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve_) => {
|
|
124
|
+
let settled = false;
|
|
125
|
+
let killTimer;
|
|
126
|
+
const stdoutChunks = [];
|
|
127
|
+
|
|
128
|
+
function settle(result) {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(killTimer);
|
|
132
|
+
try { child && child.kill(); } catch {}
|
|
133
|
+
resolve_(result);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
killTimer = setTimeout(() => {
|
|
137
|
+
process.stderr.write('[sparda] --probe: FastAPI probe timeout; using static floor.\n');
|
|
138
|
+
settle(parsePythonOutput(Buffer.concat(stdoutChunks).toString('utf8')));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
|
|
141
|
+
let child;
|
|
142
|
+
try {
|
|
143
|
+
child = spawn(python, [PY_PROBE, entryFile], {
|
|
144
|
+
cwd: projectRoot,
|
|
145
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
process.stderr.write(`[sparda] --probe: failed to spawn python: ${err.message}\n`);
|
|
150
|
+
clearTimeout(killTimer);
|
|
151
|
+
resolve_([]);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
|
|
156
|
+
child.stderr?.on('data', () => {});
|
|
157
|
+
child.on('error', (err) => {
|
|
158
|
+
process.stderr.write(`[sparda] --probe: python child error: ${err.message}\n`);
|
|
159
|
+
settle([]);
|
|
160
|
+
});
|
|
161
|
+
child.on('exit', () => {
|
|
162
|
+
settle(parsePythonOutput(Buffer.concat(stdoutChunks).toString('utf8')));
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parsePythonOutput(raw) {
|
|
168
|
+
try {
|
|
169
|
+
const arr = JSON.parse(raw.trim());
|
|
170
|
+
if (!Array.isArray(arr)) return [];
|
|
171
|
+
return arr.map((r) => ({
|
|
172
|
+
method: normalizeMethod(r.method),
|
|
173
|
+
path: normalizeFastAPIParams(r.path ?? '/'),
|
|
174
|
+
pathParams: extractPathParams(r.path ?? '/'),
|
|
175
|
+
source: 'dynamic',
|
|
176
|
+
writeClass: WRITE_METHODS.has(normalizeMethod(r.method)) ? 'write' : 'read',
|
|
177
|
+
}));
|
|
178
|
+
} catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Python resolver (cross-platform, §A.5.4 + §A.6) ──────────────────────────
|
|
184
|
+
//
|
|
185
|
+
// §A.5.4: use spawnSync with separate args array — no shell string, works on
|
|
186
|
+
// Windows cmd.exe (no `||`, no `2>/dev/null`).
|
|
187
|
+
// §A.6: 1500 ms timeout kills MS Store stub that never returns.
|
|
188
|
+
|
|
189
|
+
async function resolvePython() {
|
|
190
|
+
const candidates = platform === 'win32'
|
|
191
|
+
? ['python', 'python3']
|
|
192
|
+
: ['python3', 'python'];
|
|
193
|
+
for (const cmd of candidates) {
|
|
194
|
+
if (await checkCommand(cmd)) return cmd;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function checkCommand(cmd) {
|
|
200
|
+
return new Promise((res) => {
|
|
201
|
+
let child;
|
|
202
|
+
// §A.6: 1500 ms timeout — kills Microsoft Store python stub that hangs
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
try { child && child.kill(); } catch {}
|
|
205
|
+
res(false);
|
|
206
|
+
}, 1500);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
child = spawn(cmd, ['--version'], { stdio: 'ignore' });
|
|
210
|
+
} catch {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
res(false);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
child.on('error', () => { clearTimeout(timer); res(false); });
|
|
217
|
+
child.on('exit', (code) => { clearTimeout(timer); res(code === 0); });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Normalization helpers ─────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function normalizeMethod(m) {
|
|
224
|
+
const upper = (m ?? 'GET').toUpperCase();
|
|
225
|
+
return upper === 'DEL' ? 'DELETE' : upper;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizePath(p) {
|
|
229
|
+
return ('/' + (p ?? '').replace(/^\/+/, '')).replace(/\/+/g, '/') || '/';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeFastAPIParams(path) {
|
|
233
|
+
return (path ?? '/').replace(/\{([^}]+)\}/g, ':$1').replace(/\/+/g, '/') || '/';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function extractPathParams(path) {
|
|
237
|
+
const params = [];
|
|
238
|
+
for (const m of (path ?? '').matchAll(/:([a-zA-Z_]\w*)/g)) params.push(m[1]);
|
|
239
|
+
for (const m of (path ?? '').matchAll(/\{([^}]+)\}/g)) {
|
|
240
|
+
if (!params.includes(m[1])) params.push(m[1]);
|
|
241
|
+
}
|
|
242
|
+
return params;
|
|
243
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPARDA — Route Reconciler
|
|
3
|
+
*
|
|
4
|
+
* Pure function. No I/O. No side effects.
|
|
5
|
+
* Merges static (AST floor) + probe (runtime-observed) route sets.
|
|
6
|
+
*
|
|
7
|
+
* Contract:
|
|
8
|
+
* - Static is the floor: a static route is always kept, never removed.
|
|
9
|
+
* - A key in both → source:'static' wins, flagged confirmed:true.
|
|
10
|
+
* - A key only in probe → source:'dynamic', pushed to gaps[].
|
|
11
|
+
* - Same inputs → identical output (sorted deterministically).
|
|
12
|
+
*
|
|
13
|
+
* Canonical key = `${METHOD} ${normalizePath(path)}`
|
|
14
|
+
* normalizePath collapses :id, {id}, :anything → :param
|
|
15
|
+
* so Express and FastAPI param styles compare equal.
|
|
16
|
+
*
|
|
17
|
+
* ESM, Node ≥ 18. Zero deps.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} Route
|
|
22
|
+
* @property {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'OPTIONS'|'HEAD'|'ALL'} method
|
|
23
|
+
* @property {string} path Express-style (/users/:id)
|
|
24
|
+
* @property {string[]} [pathParams]
|
|
25
|
+
* @property {'static'|'dynamic'|'hybrid'} source
|
|
26
|
+
* @property {boolean} [confirmed] true when found in both sets
|
|
27
|
+
* @property {'read'|'write'} [writeClass]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Route[]} staticRoutes From parseExpressProject / parseFastAPIProject
|
|
32
|
+
* @param {Route[]} probedRoutes From probeRoutes()
|
|
33
|
+
* @returns {{ routes: Route[], gaps: Route[], staticCount: number, dynamicCount: number }}
|
|
34
|
+
*/
|
|
35
|
+
export function reconcile(staticRoutes, probedRoutes) {
|
|
36
|
+
// Fast path: probe empty → return static unchanged
|
|
37
|
+
if (!probedRoutes || probedRoutes.length === 0) {
|
|
38
|
+
const sorted = sortRoutes([...staticRoutes]);
|
|
39
|
+
return {
|
|
40
|
+
routes: sorted,
|
|
41
|
+
gaps: [],
|
|
42
|
+
staticCount: staticRoutes.length,
|
|
43
|
+
dynamicCount: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Index static routes by canonical key
|
|
48
|
+
const staticIndex = new Map();
|
|
49
|
+
for (const r of staticRoutes) {
|
|
50
|
+
const key = routeKey(r);
|
|
51
|
+
if (!staticIndex.has(key)) {
|
|
52
|
+
// Clone so we don't mutate the caller's objects
|
|
53
|
+
staticIndex.set(key, { ...r });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const gaps = [];
|
|
58
|
+
|
|
59
|
+
for (const dyn of probedRoutes) {
|
|
60
|
+
const key = routeKey(dyn);
|
|
61
|
+
if (staticIndex.has(key)) {
|
|
62
|
+
// Confirm: enrich the static entry
|
|
63
|
+
const existing = staticIndex.get(key);
|
|
64
|
+
existing.confirmed = true;
|
|
65
|
+
// source stays 'static' — static wins per spec
|
|
66
|
+
} else {
|
|
67
|
+
// Gap: probe found something the AST missed
|
|
68
|
+
const gap = {
|
|
69
|
+
method: (dyn.method ?? 'GET').toUpperCase(),
|
|
70
|
+
path: normalizeFastAPIParams(dyn.path ?? '/'),
|
|
71
|
+
pathParams: extractPathParams(dyn.path ?? '/'),
|
|
72
|
+
source: 'dynamic',
|
|
73
|
+
confirmed: false,
|
|
74
|
+
writeClass: dyn.writeClass ?? inferWriteClass(dyn.method),
|
|
75
|
+
};
|
|
76
|
+
gaps.push(gap);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Union: all static (some confirmed) + gaps
|
|
81
|
+
const union = [...staticIndex.values(), ...gaps];
|
|
82
|
+
const sorted = sortRoutes(union);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
routes: sorted,
|
|
86
|
+
gaps,
|
|
87
|
+
staticCount: staticRoutes.length,
|
|
88
|
+
dynamicCount: gaps.length,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Canonical key: METHOD + normalized path.
|
|
96
|
+
* /users/:id === /users/{id} === /users/:userId (all become /users/:param)
|
|
97
|
+
*/
|
|
98
|
+
function routeKey(route) {
|
|
99
|
+
const method = (route.method ?? 'GET').toUpperCase();
|
|
100
|
+
const path = normalizePath(route.path ?? '/');
|
|
101
|
+
return `${method} ${path}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Collapse :param and {param} to :param, lowercase, deduplicate slashes.
|
|
106
|
+
* Used only for comparison (key generation).
|
|
107
|
+
*/
|
|
108
|
+
function normalizePath(path) {
|
|
109
|
+
return (path ?? '/')
|
|
110
|
+
.replace(/\{[^}]+\}/g, ':param') // FastAPI {id}
|
|
111
|
+
.replace(/:[a-zA-Z_]\w*/g, ':param') // Express :id
|
|
112
|
+
.replace(/\*\*[^/]*/g, ':wildcard') // FastAPI **rest
|
|
113
|
+
.replace(/\/+/g, '/')
|
|
114
|
+
.replace(/\/$/, '') // strip trailing slash (except root)
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
|| '/';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize FastAPI {param} → :param for the stored path field.
|
|
121
|
+
* Express :param style is already canonical.
|
|
122
|
+
*/
|
|
123
|
+
function normalizeFastAPIParams(path) {
|
|
124
|
+
return (path ?? '/')
|
|
125
|
+
.replace(/\{([^}]+)\}/g, ':$1') // {id} → :id
|
|
126
|
+
.replace(/\/+/g, '/')
|
|
127
|
+
|| '/';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Extract ordered param names from a path (Express or FastAPI style). */
|
|
131
|
+
function extractPathParams(path) {
|
|
132
|
+
const params = [];
|
|
133
|
+
// Express :param
|
|
134
|
+
for (const m of (path ?? '').matchAll(/:([a-zA-Z_]\w*)/g)) {
|
|
135
|
+
params.push(m[1]);
|
|
136
|
+
}
|
|
137
|
+
// FastAPI {param}
|
|
138
|
+
for (const m of (path ?? '').matchAll(/\{([^}]+)\}/g)) {
|
|
139
|
+
if (!params.includes(m[1])) params.push(m[1]);
|
|
140
|
+
}
|
|
141
|
+
return params;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function inferWriteClass(method) {
|
|
145
|
+
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes((method ?? '').toUpperCase())
|
|
146
|
+
? 'write'
|
|
147
|
+
: 'read';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Deterministic sort: by canonical key (METHOD path).
|
|
152
|
+
* Same inputs always produce the same order.
|
|
153
|
+
*/
|
|
154
|
+
function sortRoutes(routes) {
|
|
155
|
+
return routes.slice().sort((a, b) => {
|
|
156
|
+
const ka = routeKey(a);
|
|
157
|
+
const kb = routeKey(b);
|
|
158
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
159
|
+
});
|
|
160
|
+
}
|
package/src/server/condenser.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// server/condenser.js —
|
|
1
|
+
// server/condenser.js — call-sequence recording (Labs): detect tool-call circuits.
|
|
2
2
|
// Deterministic, zero LLM, default OFF. Observes the session's current of tool
|
|
3
3
|
// calls and detects circuits — an output value of call A feeding an argument of
|
|
4
4
|
// a later call B. Persists STRUCTURE only (tool names, arg names, counts) into
|
|
5
5
|
// sparda.json: payload values can be PII and the manifest is committed to git.
|
|
6
6
|
import fs from 'node:fs';
|
|
7
|
+
import { writeManifestSync } from './persistence.js';
|
|
7
8
|
|
|
8
9
|
const WINDOW = 20; // calls remembered per session (ring)
|
|
9
10
|
const MAX_VALUES = 50; // scalars kept per payload
|
|
@@ -80,7 +81,7 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
|
|
|
80
81
|
try {
|
|
81
82
|
const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
82
83
|
onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
|
|
83
|
-
|
|
84
|
+
writeManifestSync(manifestPath, onDisk);
|
|
84
85
|
} catch { /* disk briefly unavailable — circuits stay in memory */ }
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// server/confirmation.js — Signal 2: out-of-band human approval the AI can never forge.
|
|
2
|
+
//
|
|
3
|
+
// Brief #1 (blast-radius). On MCP clients WITHOUT `elicitation`, SPARDA's two-phase write
|
|
4
|
+
// confirmation was forgeable: the AI received a confirm token (Signal 1, over stdio) and
|
|
5
|
+
// replayed it itself via `sparda_confirm`, with no human in the loop. This module adds
|
|
6
|
+
// Signal 2 — a click on a native OS dialog spawned on the operator's desktop, a channel a
|
|
7
|
+
// prompt-injected (or otherwise misbehaving) AI cannot reach. A write proceeds only when
|
|
8
|
+
// BOTH signals are present: Signal 1 is necessary-but-not-sufficient, by construction.
|
|
9
|
+
//
|
|
10
|
+
// Where it lives: the BRIDGE. Framework-agnostic — no template, no Express/FastAPI parity,
|
|
11
|
+
// no R4/R6 surface. The host's confirm token (single-use, already bound host-side to
|
|
12
|
+
// {tool, args}) is the key here, so no second binding is re-implemented in the bridge.
|
|
13
|
+
// R1 (host never pays): the dialog is spawned async, on a *gated write* only — never on a
|
|
14
|
+
// read, never on the request path; confirmWrite() is an O(1) map lookup. R8 (no new dep):
|
|
15
|
+
// node:child_process only. value-free: the dialog shows method+path+token-prefix, never an
|
|
16
|
+
// argument value. R2 (stdout is the protocol): the only log here (headless fail-closed)
|
|
17
|
+
// goes to stderr.
|
|
18
|
+
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
// the human's approval window. Defaults to the host's confirm-token TTL so the dialog never
|
|
22
|
+
// outlives the token it guards — both read the same env, kept in lockstep without coupling.
|
|
23
|
+
const TIMEOUT_MS = Number(process.env.SPARDA_CONFIRM_TTL_MS ?? 120_000);
|
|
24
|
+
const MAX_PENDING = 64; // R1: bounded RAM — far past any human's concurrent approvals
|
|
25
|
+
const S1 = 'signal1_pending';
|
|
26
|
+
const S2 = 'signal2_received';
|
|
27
|
+
const DENIED = 'denied';
|
|
28
|
+
|
|
29
|
+
const pendingWrites = new Map(); // token -> { state, expiresAt, label }
|
|
30
|
+
|
|
31
|
+
// testability: tests inject a provider so `vitest run` never spawns a real dialog.
|
|
32
|
+
let dialogProvider = null;
|
|
33
|
+
|
|
34
|
+
function evictOldest() {
|
|
35
|
+
// bounded RAM: drop the entry nearest expiry (Map preserves insertion order).
|
|
36
|
+
const oldest = pendingWrites.keys().next().value;
|
|
37
|
+
if (oldest !== undefined) pendingWrites.delete(oldest);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sweep(now = Date.now()) {
|
|
41
|
+
for (const [tok, e] of pendingWrites) if (now > e.expiresAt) pendingWrites.delete(tok);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Signal 1: the AI proposed a gated write. Register it and open the dialog (async). ──
|
|
45
|
+
// `token` is the host's single-use confirm nonce (the key); `label` is e.g. "POST /users/42".
|
|
46
|
+
// Returns immediately (R1). Idempotent per token. No-op when token is not a non-empty string.
|
|
47
|
+
export function initiateWrite({ token, label } = {}) {
|
|
48
|
+
if (typeof token !== 'string' || !token) return;
|
|
49
|
+
sweep();
|
|
50
|
+
if (pendingWrites.has(token)) return; // dialog already armed for this token
|
|
51
|
+
if (pendingWrites.size >= MAX_PENDING) evictOldest();
|
|
52
|
+
pendingWrites.set(token, { state: S1, expiresAt: Date.now() + TIMEOUT_MS, label: String(label ?? token.slice(0, 12)) });
|
|
53
|
+
// Signal 2 runs in parallel — it NEVER blocks the MCP response.
|
|
54
|
+
setImmediate(() => { requestSignal2(token).catch(() => markDenied(token)); });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Pre-approval: the human already said yes out-of-band via native elicitation (client UI). ──
|
|
58
|
+
// Marks Signal 2 satisfied WITHOUT a second prompt — no double-confirmation for elicitation clients.
|
|
59
|
+
export function preapproveWrite(token) {
|
|
60
|
+
if (typeof token !== 'string' || !token) return;
|
|
61
|
+
sweep();
|
|
62
|
+
if (!pendingWrites.has(token) && pendingWrites.size >= MAX_PENDING) evictOldest();
|
|
63
|
+
pendingWrites.set(token, { state: S2, expiresAt: Date.now() + TIMEOUT_MS, label: 'elicitation' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function requestSignal2(token) {
|
|
67
|
+
const entry = pendingWrites.get(token);
|
|
68
|
+
if (!entry) return;
|
|
69
|
+
const approved = dialogProvider
|
|
70
|
+
? await dialogProvider(entry.label, token)
|
|
71
|
+
: await openOSDialog(entry.label, token);
|
|
72
|
+
const e = pendingWrites.get(token);
|
|
73
|
+
if (!e || e.state !== S1) return; // consumed, expired, or pre-approved meanwhile
|
|
74
|
+
if (Date.now() > e.expiresAt) { pendingWrites.delete(token); return; }
|
|
75
|
+
e.state = approved ? S2 : DENIED;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function markDenied(token) {
|
|
79
|
+
const e = pendingWrites.get(token);
|
|
80
|
+
if (e && e.state === S1) e.state = DENIED;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── confirmWrite: what `sparda_confirm` calls before forwarding to the host. ──
|
|
84
|
+
// Succeeds ONLY if Signal 2 was received. Burns the token on every terminal answer; an
|
|
85
|
+
// awaiting-human answer is kept so the AI can retry once the operator approves.
|
|
86
|
+
export function confirmWrite(token) {
|
|
87
|
+
if (typeof token !== 'string' || !token) return { ok: false, reason: 'invalid_input' };
|
|
88
|
+
sweep();
|
|
89
|
+
const entry = pendingWrites.get(token);
|
|
90
|
+
if (!entry) return { ok: false, reason: 'unknown_token' };
|
|
91
|
+
if (Date.now() > entry.expiresAt) { pendingWrites.delete(token); return { ok: false, reason: 'expired' }; }
|
|
92
|
+
switch (entry.state) {
|
|
93
|
+
case S1: return { ok: false, reason: 'awaiting_human' }; // keep — the AI must retry after the click
|
|
94
|
+
case DENIED: pendingWrites.delete(token); return { ok: false, reason: 'human_denied' };
|
|
95
|
+
case S2: pendingWrites.delete(token); return { ok: true }; // burn — single use
|
|
96
|
+
default: pendingWrites.delete(token); return { ok: false, reason: 'invalid_state' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Build the argv for a native yes/no dialog on this platform, or null when there is no
|
|
101
|
+
// reachable display (→ caller fails closed). The message is handed to win32/darwin through
|
|
102
|
+
// the child's env (SPARDA_DLG_MSG), never interpolated into a script — so no argument value
|
|
103
|
+
// or injected string can break out into a shell. Linux passes it as a literal argv element
|
|
104
|
+
// (spawn does not invoke a shell), which is equally injection-safe.
|
|
105
|
+
export function buildDialogSpawn(platform, env, msg, title) {
|
|
106
|
+
if (platform === 'win32') {
|
|
107
|
+
// Windows PowerShell 5.1 (powershell.exe, always present) + WinForms MessageBox.
|
|
108
|
+
// TopMost owner form so the box surfaces; default button = No (deny). Yes → exit 0.
|
|
109
|
+
const ps = [
|
|
110
|
+
'Add-Type -AssemblyName System.Windows.Forms;',
|
|
111
|
+
'$o = New-Object System.Windows.Forms.Form -Property @{TopMost=$true};',
|
|
112
|
+
'$r = [System.Windows.Forms.MessageBox]::Show($o, $env:SPARDA_DLG_MSG, $env:SPARDA_DLG_TITLE,',
|
|
113
|
+
'[System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning,',
|
|
114
|
+
'[System.Windows.Forms.MessageBoxDefaultButton]::Button2);',
|
|
115
|
+
'if ($r -eq [System.Windows.Forms.DialogResult]::Yes) { exit 0 } else { exit 1 }',
|
|
116
|
+
].join(' ');
|
|
117
|
+
return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', ps] };
|
|
118
|
+
}
|
|
119
|
+
if (platform === 'darwin') {
|
|
120
|
+
// osascript reads the message from the inherited env (system attribute) — no interpolation.
|
|
121
|
+
const osa = 'display dialog (system attribute "SPARDA_DLG_MSG") with title (system attribute "SPARDA_DLG_TITLE") '
|
|
122
|
+
+ 'buttons {"Deny", "Allow"} default button "Deny" cancel button "Deny" with icon caution';
|
|
123
|
+
return { cmd: 'osascript', args: ['-e', osa] };
|
|
124
|
+
}
|
|
125
|
+
// Linux/BSD: zenity needs an X or Wayland display. Without one, fail closed.
|
|
126
|
+
if (env.DISPLAY || env.WAYLAND_DISPLAY) {
|
|
127
|
+
return { cmd: 'zenity', args: ['--question', `--title=${title}`, `--text=${msg}`, '--ok-label=Allow', '--cancel-label=Deny', '--width=420'] };
|
|
128
|
+
}
|
|
129
|
+
return null; // headless → caller denies
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function openOSDialog(label, token) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const title = 'SPARDA — Write confirmation';
|
|
135
|
+
const msg = `${label}\nToken: ${token.slice(0, 12)}…\n\nAllow this write on your live app?`;
|
|
136
|
+
const plan = buildDialogSpawn(process.platform, process.env, msg, title);
|
|
137
|
+
if (!plan) {
|
|
138
|
+
// R2: human log to stderr. Fail closed — no approval channel means no write.
|
|
139
|
+
process.stderr.write(`[sparda] no desktop display — write auto-denied (Signal 2 unreachable): ${label}\n`);
|
|
140
|
+
return resolve(false);
|
|
141
|
+
}
|
|
142
|
+
let proc;
|
|
143
|
+
try {
|
|
144
|
+
proc = spawn(plan.cmd, plan.args, {
|
|
145
|
+
env: { ...process.env, SPARDA_DLG_MSG: msg, SPARDA_DLG_TITLE: title },
|
|
146
|
+
windowsHide: true, // hide the helper console; the GUI dialog still shows
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
return resolve(false); // spawner missing → deny
|
|
150
|
+
}
|
|
151
|
+
const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } resolve(false); }, TIMEOUT_MS);
|
|
152
|
+
proc.on('close', (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
153
|
+
proc.on('error', () => { clearTimeout(timer); resolve(false); }); // binary absent (e.g. no zenity) → deny
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// test hooks: inject a fake dialog, inspect/clear the pending map. Never used in production.
|
|
158
|
+
export const _confirmTestHooks = {
|
|
159
|
+
setDialogProvider: (fn) => { dialogProvider = fn; },
|
|
160
|
+
clearDialogProvider: () => { dialogProvider = null; },
|
|
161
|
+
getPendingCount: () => pendingWrites.size,
|
|
162
|
+
clearAll: () => pendingWrites.clear(),
|
|
163
|
+
TIMEOUT_MS,
|
|
164
|
+
MAX_PENDING,
|
|
165
|
+
};
|