sparda-mcp 0.5.1 → 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,322 @@
1
+ /**
2
+ * OpaqueContextCarrier — SPARDA Bridge
3
+ *
4
+ * Pattern: Passive, operator-pinned transport of caller identity/context
5
+ * from bridge launch into every forwarded host request.
6
+ *
7
+ * §0 correction (CLAUDE.md): context is resolved ONCE at bridge startup
8
+ * from operator-supplied sources (CLI flag > env var > sparda.json).
9
+ * It is NEVER caller-supplied per-call — the AI cannot choose the tenant.
10
+ *
11
+ * Domain transfer:
12
+ * Optical fiber — passive transport, selectivity at the edges
13
+ * Blind signatures — forward without seeing or interpreting
14
+ * Membrane transport — channel without selectivity; host boundary enforces
15
+ *
16
+ * Invariants:
17
+ * 1. Context resolved once at startup; frozen thereafter.
18
+ * 2. Forwarded verbatim on every host call — never interpreted, never modified.
19
+ * 3. Bounded memory: max 8 headers × 1024 bytes.
20
+ * 4. CRLF guard: illegal control chars → startup throw, bridge does not start.
21
+ * 5. Value-free observability: logs name + 8-hex SHA-256 only, never the value.
22
+ * 6. Absent config → feature off, zero overhead, byte-identical forwarding.
23
+ */
24
+
25
+ import { createHash } from 'node:crypto';
26
+
27
+ // ─── Constants ────────────────────────────────────────────────────────────────
28
+
29
+ const MAX_HEADERS = 8;
30
+ const MAX_VALUE_BYTES = 1024;
31
+
32
+ /**
33
+ * Env var naming convention:
34
+ * Header name → env var
35
+ * X-Tenant-Id → SPARDA_CONTEXT_X_Tenant_Id
36
+ * Rule: prepend SPARDA_CONTEXT_, replace hyphens with underscores.
37
+ *
38
+ * Rationale: hyphens are invalid in POSIX env var names; underscores are safe
39
+ * on all platforms (Linux case-sensitive, Windows case-insensitive — both fine).
40
+ */
41
+ const ENV_PREFIX = 'SPARDA_CONTEXT_';
42
+
43
+ /** CLI flag: --context Header-Name=value (repeatable) */
44
+ const CLI_FLAG = '--context';
45
+
46
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Convert a header name to its env var counterpart.
50
+ * X-Tenant-Id → SPARDA_CONTEXT_X_Tenant_Id
51
+ *
52
+ * @param {string} headerName
53
+ * @returns {string}
54
+ */
55
+ function headerToEnvKey(headerName) {
56
+ return ENV_PREFIX + headerName.replace(/-/g, '_');
57
+ }
58
+
59
+ /**
60
+ * CRLF guard — fail closed at startup.
61
+ * A value containing CR, LF, or NUL could split the HTTP request
62
+ * and inject arbitrary headers. We refuse to start, never silently strip.
63
+ *
64
+ * @param {string} headerName
65
+ * @param {string} value
66
+ * @throws {Error} code:'USER' if illegal control chars present
67
+ */
68
+ function assertNoCRLF(headerName, value) {
69
+ if (/[\r\n\0]/.test(value)) {
70
+ throw Object.assign(
71
+ new Error(`context value for "${headerName}" has illegal control chars`),
72
+ {
73
+ code: 'USER',
74
+ hint: `${headerName} must be a single header line — no CR, LF, or NUL`
75
+ }
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Validate contextPropagation config from sparda.json.
82
+ * Fail-closed: anything outside the schema is rejected.
83
+ *
84
+ * Note: `from` field is optional in the new model (values come from CLI/env).
85
+ * If present it may be "launch" or omitted entirely — "mcp.session.metadata"
86
+ * is rejected (that source is gone per §0).
87
+ *
88
+ * @param {unknown} config
89
+ * @returns {{ valid: boolean, error?: Error }}
90
+ */
91
+ export function validateConfig(config) {
92
+ if (config === null || config === undefined) {
93
+ return { valid: true }; // absent = feature off, not an error
94
+ }
95
+
96
+ if (typeof config !== 'object' || Array.isArray(config)) {
97
+ return {
98
+ valid: false,
99
+ error: Object.assign(
100
+ new Error('contextPropagation must be an object'),
101
+ { code: 'USER', hint: 'Remove the key or provide a valid object' }
102
+ )
103
+ };
104
+ }
105
+
106
+ // mode must be "verbatim" if present
107
+ if (config.mode !== undefined && config.mode !== 'verbatim') {
108
+ return {
109
+ valid: false,
110
+ error: Object.assign(
111
+ new Error('contextPropagation.mode must be "verbatim"'),
112
+ { code: 'USER', hint: 'Only "verbatim" is supported — SPARDA never interprets context' }
113
+ )
114
+ };
115
+ }
116
+
117
+ // from: "mcp.session.metadata" is the old, insecure source — reject explicitly
118
+ if (config.from === 'mcp.session.metadata') {
119
+ return {
120
+ valid: false,
121
+ error: Object.assign(
122
+ new Error('contextPropagation.from "mcp.session.metadata" is not supported (§0: caller-supplied context is insecure)'),
123
+ { code: 'USER', hint: 'Remove "from" or set it to "launch". Values come from CLI flag or env vars.' }
124
+ )
125
+ };
126
+ }
127
+
128
+ // from: if present, must be "launch"
129
+ if (config.from !== undefined && config.from !== 'launch') {
130
+ return {
131
+ valid: false,
132
+ error: Object.assign(
133
+ new Error('contextPropagation.from must be "launch" or absent'),
134
+ { code: 'USER', hint: 'Only operator-pinned launch-time context is supported' }
135
+ )
136
+ };
137
+ }
138
+
139
+ // headers must be an array if present
140
+ if (config.headers !== undefined) {
141
+ if (!Array.isArray(config.headers)) {
142
+ return {
143
+ valid: false,
144
+ error: Object.assign(
145
+ new Error('contextPropagation.headers must be an array'),
146
+ { code: 'USER', hint: 'Provide an array of header name strings' }
147
+ )
148
+ };
149
+ }
150
+
151
+ if (config.headers.length > MAX_HEADERS) {
152
+ return {
153
+ valid: false,
154
+ error: Object.assign(
155
+ new Error(`contextPropagation.headers exceeds max ${MAX_HEADERS} items`),
156
+ { code: 'USER', hint: `Reduce to ${MAX_HEADERS} headers or fewer` }
157
+ )
158
+ };
159
+ }
160
+
161
+ for (const h of config.headers) {
162
+ if (typeof h !== 'string' || h.length === 0) {
163
+ return {
164
+ valid: false,
165
+ error: Object.assign(
166
+ new Error('Each header must be a non-empty string'),
167
+ { code: 'USER', hint: 'Check all items in contextPropagation.headers' }
168
+ )
169
+ };
170
+ }
171
+ }
172
+ }
173
+
174
+ return { valid: true };
175
+ }
176
+
177
+ // ─── Core API ─────────────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * resolveContext — called ONCE at bridge startup.
181
+ *
182
+ * Resolves the operator-pinned context in this precedence (first present wins):
183
+ * 1. CLI flags: --context X-Tenant-Id=acme (repeatable, one per header)
184
+ * 2. Env vars: SPARDA_CONTEXT_X_Tenant_Id=acme
185
+ * 3. sparda.json: contextPropagation.headers declares which headers to look for;
186
+ * their values still come from CLI / env (never hardcoded in JSON).
187
+ *
188
+ * If contextPropagation is absent → returns a frozen empty object (feature off).
189
+ *
190
+ * Throws USER error at startup if:
191
+ * - config is invalid
192
+ * - any pinned value contains CR, LF, or NUL (CRLF guard)
193
+ * - more than MAX_HEADERS headers
194
+ * - any value exceeds MAX_VALUE_BYTES
195
+ *
196
+ * @param {{ argv?: string[], env?: Record<string,string>, config?: unknown }} opts
197
+ * @returns {Readonly<{ headers: Readonly<Record<string,string>> }>}
198
+ */
199
+ export function resolveContext({ argv = [], env = {}, config = null } = {}) {
200
+ // Feature off when no config
201
+ if (!config || !config.headers || config.headers.length === 0) {
202
+ return Object.freeze({ headers: Object.freeze({}) });
203
+ }
204
+
205
+ const validation = validateConfig(config);
206
+ if (!validation.valid) {
207
+ throw validation.error;
208
+ }
209
+
210
+ const declaredHeaders = config.headers; // already validated: array of non-empty strings
211
+ const resolved = {};
212
+
213
+ // Parse CLI flags once: --context Key=value (repeatable)
214
+ const cliValues = {};
215
+ for (let i = 0; i < argv.length; i++) {
216
+ if (argv[i] === CLI_FLAG && i + 1 < argv.length) {
217
+ const pair = argv[i + 1];
218
+ const eq = pair.indexOf('=');
219
+ if (eq > 0) {
220
+ const k = pair.slice(0, eq);
221
+ const v = pair.slice(eq + 1);
222
+ cliValues[k] = v;
223
+ }
224
+ i++; // skip the value token
225
+ } else if (argv[i].startsWith(CLI_FLAG + '=')) {
226
+ // --context=Key=value form (less common but handle it)
227
+ const rest = argv[i].slice(CLI_FLAG.length + 1);
228
+ const eq = rest.indexOf('=');
229
+ if (eq > 0) {
230
+ cliValues[rest.slice(0, eq)] = rest.slice(eq + 1);
231
+ }
232
+ } else if (argv[i].startsWith(CLI_FLAG + ' ')) {
233
+ // shouldn't happen after shell splitting, but be safe
234
+ }
235
+ }
236
+
237
+ for (const headerName of declaredHeaders) {
238
+ let value;
239
+
240
+ // 1. CLI flag (highest precedence)
241
+ if (Object.prototype.hasOwnProperty.call(cliValues, headerName)) {
242
+ value = cliValues[headerName];
243
+ }
244
+ // 2. Env var
245
+ else {
246
+ const envKey = headerToEnvKey(headerName);
247
+ if (Object.prototype.hasOwnProperty.call(env, envKey)) {
248
+ value = env[envKey];
249
+ }
250
+ }
251
+ // 3. Not found → skip (feature gracefully absent for this header)
252
+
253
+ if (value !== undefined) {
254
+ // CRLF guard — fail closed, never strip
255
+ assertNoCRLF(headerName, value);
256
+
257
+ // Bounds check — measure BYTES, not UTF-16 code units: a multibyte value
258
+ // (é, 你, 😀) can be ≤ MAX_VALUE_BYTES in .length yet blow the byte budget.
259
+ // The bound is named *_BYTES and the wire is bytes, so byteLength is correct.
260
+ if (Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) {
261
+ throw Object.assign(
262
+ new Error(`context value for "${headerName}" exceeds ${MAX_VALUE_BYTES} bytes`),
263
+ { code: 'USER', hint: 'Shorten the value or split across multiple headers' }
264
+ );
265
+ }
266
+
267
+ resolved[headerName] = value;
268
+ }
269
+ }
270
+
271
+ // Bounds: total header count (defensive — already bounded by declaredHeaders.length ≤ 8)
272
+ if (Object.keys(resolved).length > MAX_HEADERS) {
273
+ throw Object.assign(
274
+ new Error(`context exceeds max ${MAX_HEADERS} headers`),
275
+ { code: 'USER', hint: `Declare at most ${MAX_HEADERS} headers in contextPropagation` }
276
+ );
277
+ }
278
+
279
+ return Object.freeze({ headers: Object.freeze(resolved) });
280
+ }
281
+
282
+ /**
283
+ * injectContext — called on EVERY forwarded host call (hot path).
284
+ *
285
+ * Copies the operator-pinned headers verbatim onto the outbound header object.
286
+ * O(1) in the number of declared headers (max 8). Zero allocation beyond the copy.
287
+ *
288
+ * The AI cannot influence this: ctx is frozen at startup.
289
+ *
290
+ * @param {Record<string,string>} outboundHeaders — mutated in place
291
+ * @param {Readonly<{ headers: Readonly<Record<string,string>> }>} ctx — from resolveContext
292
+ */
293
+ export function injectContext(outboundHeaders, ctx) {
294
+ if (!ctx || !ctx.headers) return;
295
+ // Verbatim copy — no transformation, no encoding, no interpretation
296
+ for (const [name, value] of Object.entries(ctx.headers)) {
297
+ outboundHeaders[name] = value;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * fingerprintContext — value-free observability.
303
+ *
304
+ * Returns header names + 8-hex SHA-256 of each value.
305
+ * Never the raw value. Safe for stderr logs (R2).
306
+ *
307
+ * @param {Readonly<{ headers: Readonly<Record<string,string>> }>} ctx
308
+ * @returns {Record<string, { present: boolean, hash: string }>}
309
+ */
310
+ export function fingerprintContext(ctx) {
311
+ const fp = {};
312
+ if (!ctx || !ctx.headers) return fp;
313
+ for (const [name, value] of Object.entries(ctx.headers)) {
314
+ fp[name] = {
315
+ present: true,
316
+ hash: createHash('sha256').update(value).digest('hex').slice(0, 8)
317
+ };
318
+ }
319
+ return fp;
320
+ }
321
+
322
+ export default { resolveContext, injectContext, fingerprintContext, validateConfig };
@@ -1,4 +1,4 @@
1
- // server/crystallize.js — ROADMAP round 2, brick 2: observation freezes state.
1
+ // server/crystallize.js — observation freezes state into a composite tool.
2
2
  // A circuit observed enough times crystallizes into a composite tool: one MCP
3
3
  // call that runs the whole chain, auto-feeding each linked argument from the
4
4
  // previous step's output (by fromKey — structure recorded at observation time,