sparda-mcp 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -1,9 +1,10 @@
1
- // server/condenser.js — ROADMAP round 2, brick 1: call-sequence recording (Labs).
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
- fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
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
+ };