@luanpdd/kit-mcp 1.12.1 → 1.14.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.
@@ -14,21 +14,70 @@
14
14
 
15
15
  import path from 'node:path';
16
16
  import fs from 'node:fs/promises';
17
+ import { redactSecrets } from './error-redaction.js';
17
18
 
18
19
  const REPLAY_DIR_REL = path.join('.planning', 'replays');
19
20
 
21
+ // SEC-13-02: replayId path traversal guard. The MCP forensics tool exposes
22
+ // load-replay/annotate-replay/record-replay actions; without sanitization,
23
+ // a malicious replayId like '../../../etc/passwd' would read/write files
24
+ // outside .planning/replays/.
25
+ //
26
+ // Strategy: allowlist regex (no slashes, no '..', no NUL) + post-resolve assertion
27
+ // that the final path stays inside REPLAY_DIR_REL.
28
+ const REPLAY_ID_RE = /^[A-Za-z0-9_.-]+$/;
29
+
30
+ function validateReplayId(id) {
31
+ if (typeof id !== 'string' || !id) {
32
+ throw new Error('invalid replay id: must be a non-empty string');
33
+ }
34
+ if (id === '.' || id === '..' || id.includes('..')) {
35
+ throw new Error('invalid replay id: traversal sequences not allowed');
36
+ }
37
+ if (!REPLAY_ID_RE.test(id)) {
38
+ throw new Error(`invalid replay id: only [A-Za-z0-9_.-] allowed, got ${JSON.stringify(id)}`);
39
+ }
40
+ return id;
41
+ }
42
+
43
+ function assertPathInside(filePath, baseDir) {
44
+ const resolved = path.resolve(filePath);
45
+ const base = path.resolve(baseDir);
46
+ // Ensure resolved is base or a child of base (handle trailing-sep edge case).
47
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
48
+ throw new Error('invalid replay id: resolved path escapes replay directory');
49
+ }
50
+ return resolved;
51
+ }
52
+
20
53
  export async function recordReplay(payload, opts = {}) {
21
54
  const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
22
55
  const dir = path.join(projectRoot, REPLAY_DIR_REL);
23
56
  await fs.mkdir(dir, { recursive: true });
24
57
 
25
58
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
26
- const slug = [payload.phase, payload.plan, payload.agent].filter(Boolean).join('-') || 'unknown';
59
+ // SEC-13-02: validate each slug component independently before concat
60
+ const slugParts = [payload.phase, payload.plan, payload.agent].filter(Boolean);
61
+ for (const part of slugParts) {
62
+ validateReplayId(String(part));
63
+ }
64
+ const slug = slugParts.join('-') || 'unknown';
27
65
  const id = `${ts}-${slug}`;
66
+ // Re-validate the full id (defense in depth — ts is well-formed but cheap to check)
67
+ validateReplayId(id);
28
68
  const file = path.join(dir, `${id}.json`);
69
+ assertPathInside(file, dir);
29
70
 
30
71
  const record = { id, recorded_at: new Date().toISOString(), ...payload };
31
- await fs.writeFile(file, JSON.stringify(record, null, 2), 'utf8');
72
+ // SEC-14-06: scrub the serialized form before writing. We redact AFTER
73
+ // JSON.stringify (rather than deep-mapping the payload tree) so the regex
74
+ // walks the entire structure including nested args/headers/env, and so
75
+ // the in-memory `record` returned to the caller stays unmutated. Only the
76
+ // on-disk artifact is scrubbed; readers of the file via loadReplay see
77
+ // the redacted form, which is the desired outcome — secrets must not be
78
+ // re-loaded into memory either.
79
+ const json = redactSecrets(JSON.stringify(record, null, 2));
80
+ await fs.writeFile(file, json, 'utf8');
32
81
  return { id, file, record };
33
82
  }
34
83
 
@@ -49,15 +98,21 @@ export async function listReplays(opts = {}) {
49
98
  }
50
99
 
51
100
  export async function loadReplay(id, opts = {}) {
101
+ validateReplayId(id);
52
102
  const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
53
- const file = path.join(projectRoot, REPLAY_DIR_REL, `${id}.json`);
103
+ const dir = path.join(projectRoot, REPLAY_DIR_REL);
104
+ const file = path.join(dir, `${id}.json`);
105
+ assertPathInside(file, dir);
54
106
  const raw = await fs.readFile(file, 'utf8');
55
107
  return JSON.parse(raw);
56
108
  }
57
109
 
58
110
  export async function annotateReplay(id, outcome, opts = {}) {
111
+ validateReplayId(id);
59
112
  const projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
60
- const file = path.join(projectRoot, REPLAY_DIR_REL, `${id}.json`);
113
+ const dir = path.join(projectRoot, REPLAY_DIR_REL);
114
+ const file = path.join(dir, `${id}.json`);
115
+ assertPathInside(file, dir);
61
116
  const r = JSON.parse(await fs.readFile(file, 'utf8'));
62
117
  r.outcome = { ...(r.outcome ?? {}), ...outcome, annotated_at: new Date().toISOString() };
63
118
  await fs.writeFile(file, JSON.stringify(r, null, 2), 'utf8');
package/src/core/sync.js CHANGED
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import fs from 'node:fs/promises';
14
14
  import { getTarget } from './registry.js';
15
15
  import { listKit, resolveKitRoot } from './kit.js';
16
+ import { verifyManifest } from './manifest-verify.js';
16
17
 
17
18
  const STUB_MARKER = '<!-- kit-mcp:reference -->';
18
19
  const MANAGED_MARKER_FILE = '.kit-mcp-managed';
@@ -26,6 +27,18 @@ export async function syncTo(targetId, opts = {}) {
26
27
  const dryRun = !!opts.dryRun;
27
28
  const onProgress = opts.onProgress ?? (() => {});
28
29
 
30
+ // SEC-14-05: verify kit integrity before projecting. Refuses tampered kit/.
31
+ // Opt-out via KIT_MCP_SKIP_MANIFEST_CHECK=1 (handled inside verifyManifest).
32
+ // Only runs on install path (syncTo); removeFrom/statusOf/applyReverse don't
33
+ // call this — see plan 83-03 for rationale (apply path is the introduction
34
+ // vector, not the trust point; stale-but-intact kits in dev are skipped).
35
+ const manifestCheck = await verifyManifest(kitRoot);
36
+ if (!manifestCheck.ok) {
37
+ const err = new Error(manifestCheck.reason);
38
+ err.code = 'EMANIFESTMISMATCH';
39
+ throw err;
40
+ }
41
+
29
42
  // PERF-03: accept a pre-loaded kit to avoid re-walking the disk when callers
30
43
  // already have one in hand (CLI sync that follows reverse-sync detect, etc).
31
44
  // PERF-S1: in mode=reference (default), read just frontmatter — body/content
@@ -257,8 +270,10 @@ See: [\`${rel}\`](${rel})
257
270
  // own file under kit/ — duplicating them here costs tokens in every Claude
258
271
  // Code session. Cap each line at ~80 chars; users can `kit get <name>` for the
259
272
  // full description.
260
- const SUMMARY_MAX_CHARS = 80;
261
- function summarize(desc) {
273
+ // PERF-13-01: exported so slim() in src/mcp-server/index.js and src/cli/index.js
274
+ // can reuse the same cap (single source of truth — no duplicated constants).
275
+ export const SUMMARY_MAX_CHARS = 80;
276
+ export function summarize(desc) {
262
277
  if (!desc) return '';
263
278
  const flat = desc.replace(/\s+/g, ' ').trim();
264
279
  if (flat.length <= SUMMARY_MAX_CHARS) return flat;
@@ -12,10 +12,16 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
12
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
13
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
14
14
 
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import path from 'node:path';
18
+
15
19
  import { listKit, searchKit, findItem } from '../core/kit.js';
16
20
  import { listTargets } from '../core/registry.js';
17
- import { syncTo, statusOf, removeFrom } from '../core/sync.js';
21
+ import { syncTo, statusOf, removeFrom, summarize } from '../core/sync.js';
18
22
  import { detectReverse, applyReverse } from '../core/reverse-sync.js';
23
+ import { validateProjectRoot } from '../core/path-safety.js';
24
+ import { sanitizeMcpError } from '../core/error-redaction.js';
19
25
  import { listGates, getGate, gatesForStage } from '../core/gates.js';
20
26
  import { runGate } from '../core/gate-runner.js';
21
27
  import { collectFailures, summarizeByAgent, writeLearnings } from '../core/failures.js';
@@ -125,6 +131,23 @@ const TOOLS = [
125
131
  },
126
132
  ];
127
133
 
134
+ // DRIFT-13-03: read version from package.json at module load (NOT inside
135
+ // createServer — re-reading on every call adds zero value). Same pattern as
136
+ // bin/cli.js:43-51. Both files are 2 levels deep from repo root, so the
137
+ // '..', '..' resolution works identically. Falls back to 'unknown' if the
138
+ // package.json lookup fails (unusual install layout).
139
+ function readPkgVersion() {
140
+ try {
141
+ const here = path.dirname(fileURLToPath(import.meta.url));
142
+ const pkgPath = path.resolve(here, '..', '..', 'package.json');
143
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
144
+ } catch {
145
+ return 'unknown';
146
+ }
147
+ }
148
+
149
+ export const PKG_VERSION = readPkgVersion();
150
+
128
151
  // --- handlers ---
129
152
 
130
153
  async function handleKit(args) {
@@ -171,25 +194,45 @@ async function withAutoSpawn(args, tool, run) {
171
194
  async function handleSync(args) {
172
195
  switch (args.action) {
173
196
  case 'targets': return listTargets();
174
- case 'status': return statusOf(args.target, { projectRoot: args.projectRoot });
197
+ case 'status':
175
198
  case 'install':
176
- return withAutoSpawn(args, 'sync.install', (onProgress) =>
177
- syncTo(args.target, { projectRoot: args.projectRoot, mode: args.mode, dryRun: args.dryRun, onProgress }));
178
- case 'remove': return removeFrom(args.target, { projectRoot: args.projectRoot });
199
+ case 'remove': {
200
+ // SEC-14-03: MCP message must specify a path inside a git workspace.
201
+ // CLI bypasses this bin/cli.js trusts whoever invoked it (same trust
202
+ // model as Phase 79.01's gates.run guard). status is read-only but
203
+ // included for defense-in-depth and a single uniform error surface.
204
+ const guard = await validateProjectRoot(args.projectRoot);
205
+ if (!guard.ok) return { error: guard.reason };
206
+ const projectRoot = guard.resolvedPath;
207
+ if (args.action === 'status') return statusOf(args.target, { projectRoot });
208
+ if (args.action === 'install')
209
+ return withAutoSpawn({ ...args, projectRoot }, 'sync.install', (onProgress) =>
210
+ syncTo(args.target, { projectRoot, mode: args.mode, dryRun: args.dryRun, onProgress }));
211
+ // action === 'remove'
212
+ return removeFrom(args.target, { projectRoot });
213
+ }
179
214
  default: return { error: `Unknown action: ${args.action}` };
180
215
  }
181
216
  }
182
217
 
183
218
  async function handleReverseSync(args) {
184
219
  switch (args.action) {
185
- case 'detect': return detectReverse(args.target, { projectRoot: args.projectRoot });
186
- case 'apply':
187
- return withAutoSpawn(args, 'reverse-sync.apply', (onProgress) =>
220
+ case 'detect':
221
+ case 'apply': {
222
+ // SEC-14-03: same guard as handleSync — reverse-sync apply also writes
223
+ // to disk (kit/<file>) so it must be on the same allowlist as sync.
224
+ const guard = await validateProjectRoot(args.projectRoot);
225
+ if (!guard.ok) return { error: guard.reason };
226
+ const projectRoot = guard.resolvedPath;
227
+ if (args.action === 'detect') return detectReverse(args.target, { projectRoot });
228
+ // action === 'apply'
229
+ return withAutoSpawn({ ...args, projectRoot }, 'reverse-sync.apply', (onProgress) =>
188
230
  applyReverse(args.target, {
189
- projectRoot: args.projectRoot,
231
+ projectRoot,
190
232
  strategy: args.strategy, only: args.only, dryRun: args.dryRun,
191
233
  onProgress,
192
234
  }));
235
+ }
193
236
  default: return { error: `Unknown action: ${args.action}` };
194
237
  }
195
238
  }
@@ -200,12 +243,14 @@ async function handleGates(args) {
200
243
  case 'get': return getGate(args.id);
201
244
  case 'for-stage': return gatesForStage(args.stage);
202
245
  case 'run':
203
- return withAutoSpawn(args, 'gates.run', () =>
204
- runGate(args.id, {
205
- projectRoot: args.projectRoot,
206
- yes: true, // MCP context: never prompt
207
- interactive: false, // MCP never prompts
208
- }));
246
+ // SEC-13-01: MCP transport must never execute shell — runGate spawns bash with
247
+ // arbitrary content from gates/*.md (which reverse-sync can rewrite). Even with
248
+ // {yes: true}, this skips the interactive "y/N before exec" promise. The CLI
249
+ // entry point (`kit gates run <id>` via bin/cli.js) preserves the prompt and
250
+ // remains the only path to executing gates.
251
+ return {
252
+ error: 'MCP gates.run requires interactive TTY confirmation; use `kit gates run` from CLI instead.',
253
+ };
209
254
  default: return { error: `Unknown action: ${args.action}` };
210
255
  }
211
256
  }
@@ -255,14 +300,16 @@ const HANDLERS = {
255
300
  function slim(x) {
256
301
  // absPath omitted by design — list-* tools are AI-consumed in tight context budgets.
257
302
  // Use action=get to fetch the absPath (and content) for a specific item.
258
- return { kind: x.kind, name: x.name, description: x.description };
303
+ // PERF-13-01 (TOK-02): truncate description via SUMMARY_MAX_CHARS (80) cap shared
304
+ // with src/core/sync.js — full description lives in each item's file under kit/.
305
+ return { kind: x.kind, name: x.name, description: summarize(x.description) };
259
306
  }
260
307
 
261
308
  // --- server bootstrap ---
262
309
 
263
310
  export async function createServer() {
264
311
  const server = new Server(
265
- { name: 'kit-mcp', version: '0.1.0' },
312
+ { name: 'kit-mcp', version: PKG_VERSION },
266
313
  { capabilities: { tools: {} } }
267
314
  );
268
315
 
@@ -278,8 +325,12 @@ export async function createServer() {
278
325
  const result = await handler(args ?? {});
279
326
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
280
327
  } catch (e) {
328
+ // SEC-14-06: full stack stays in stderr for operator debug; client envelope is sanitized.
329
+ // sanitizeMcpError redacts secrets/paths from e.message, preserves e.code (Phase 83
330
+ // EMANIFESTMISMATCH invariant), and emits NO stack field.
331
+ console.error('[mcp-server] error in handler:', e?.stack ?? e);
281
332
  return {
282
- content: [{ type: 'text', text: JSON.stringify({ error: e.message, stack: e.stack }, null, 2) }],
333
+ content: [{ type: 'text', text: JSON.stringify(sanitizeMcpError(e), null, 2) }],
283
334
  isError: true,
284
335
  };
285
336
  }
@@ -97,7 +97,12 @@ export async function ensureSidecar({ projectRoot, openBrowserOnSpawn = true } =
97
97
 
98
98
  let opened = false;
99
99
  if (openBrowserOnSpawn) {
100
- const url = `http://127.0.0.1:${lock.port}/`;
100
+ // SEC-14-02: propagate auth token via query param so browser can self-authenticate
101
+ // without user interaction. EventSource cannot send custom headers; ?t= is the
102
+ // canonical pattern. The browser scrubs ?t= from the address bar via
103
+ // history.replaceState immediately on boot to avoid leak via screenshare.
104
+ const tokenSuffix = lock.token ? `?t=${encodeURIComponent(lock.token)}` : '';
105
+ const url = `http://127.0.0.1:${lock.port}/${tokenSuffix}`;
101
106
  const r = await openBrowser(url);
102
107
  opened = r.opened === true;
103
108
  }
package/src/ui/client.js CHANGED
@@ -8,34 +8,43 @@ import http from 'node:http';
8
8
  import { readLock } from './lockfile.js';
9
9
  import { validateEvent } from './events.js';
10
10
 
11
- // Cache the resolved port across calls in a single process.
12
- const portCache = new Map(); // projectRoot -> port (or 0 = no sidecar)
13
- const PORT_CACHE_TTL_MS = 5_000;
11
+ // Cache the resolved sidecar (port + token) across calls in a single process.
12
+ // SEC-14-02: token is needed for Authorization on every publish() read from
13
+ // the same lockfile read as port to avoid double I/O.
14
+ const sidecarCache = new Map(); // projectRoot -> { port, token } | { port: 0, token: null }
15
+ const SIDECAR_CACHE_TTL_MS = 5_000;
14
16
  const cacheTimestamps = new Map();
15
17
 
16
- function readCachedPort(projectRoot) {
18
+ function readCachedSidecar(projectRoot) {
17
19
  const ts = cacheTimestamps.get(projectRoot);
18
- if (!ts || Date.now() - ts > PORT_CACHE_TTL_MS) return undefined;
19
- return portCache.get(projectRoot);
20
+ if (!ts || Date.now() - ts > SIDECAR_CACHE_TTL_MS) return undefined;
21
+ return sidecarCache.get(projectRoot);
20
22
  }
21
23
 
22
- function writeCachedPort(projectRoot, port) {
23
- portCache.set(projectRoot, port);
24
+ function writeCachedSidecar(projectRoot, sidecar) {
25
+ sidecarCache.set(projectRoot, sidecar);
24
26
  cacheTimestamps.set(projectRoot, Date.now());
25
27
  }
26
28
 
29
+ // Backward-compat name; clears port + token cache. Tests + callers using
30
+ // clearPortCache continue to work without code change.
27
31
  export function clearPortCache() {
28
- portCache.clear();
32
+ sidecarCache.clear();
29
33
  cacheTimestamps.clear();
30
34
  }
31
35
 
32
- function resolvePort(projectRoot) {
33
- const cached = readCachedPort(projectRoot);
36
+ function resolveSidecar(projectRoot) {
37
+ const cached = readCachedSidecar(projectRoot);
34
38
  if (cached !== undefined) return cached;
35
39
  const lock = readLock(projectRoot);
36
- const port = lock?.port ?? 0;
37
- writeCachedPort(projectRoot, port);
38
- return port;
40
+ const sidecar = {
41
+ port: lock?.port ?? 0,
42
+ // SEC-14-02: null if missing (lockfile from older sidecar version pre-v1.14).
43
+ // Triggers degraded path: no Authorization header → server 401 → soft-fail.
44
+ token: typeof lock?.token === 'string' ? lock.token : null,
45
+ };
46
+ writeCachedSidecar(projectRoot, sidecar);
47
+ return sidecar;
39
48
  }
40
49
 
41
50
  // publish(event, { projectRoot, timeoutMs }): always resolves. Returns
@@ -47,7 +56,7 @@ export async function publish(event, { projectRoot, timeoutMs = 1500 } = {}) {
47
56
  const validationErr = validateEvent(event);
48
57
  if (validationErr) return { sent: false, reason: `invalid_event: ${validationErr.message}` };
49
58
 
50
- const port = resolvePort(projectRoot);
59
+ const { port, token } = resolveSidecar(projectRoot);
51
60
  if (!port) return { sent: false, reason: 'no_sidecar' };
52
61
 
53
62
  const body = JSON.stringify(event);
@@ -65,6 +74,10 @@ export async function publish(event, { projectRoot, timeoutMs = 1500 } = {}) {
65
74
  'content-length': Buffer.byteLength(body, 'utf8'),
66
75
  'origin': `http://127.0.0.1:${port}`,
67
76
  'connection': 'close',
77
+ // SEC-14-02: attach Bearer token if lockfile has one. If not (older
78
+ // sidecar pre-v1.14), server returns 401 → resolves as { sent: false,
79
+ // reason: 'http_401' } via the soft-fail flow below.
80
+ ...(token ? { 'authorization': `Bearer ${token}` } : {}),
68
81
  },
69
82
  }, (res) => {
70
83
  // Drain — we don't actually care about the body, just the status.
@@ -73,9 +86,11 @@ export async function publish(event, { projectRoot, timeoutMs = 1500 } = {}) {
73
86
  if (res.statusCode >= 200 && res.statusCode < 300) {
74
87
  resolve({ sent: true, status: res.statusCode });
75
88
  } else {
76
- // Stale lockfile? Drop the cache so the next call re-reads.
77
- if (res.statusCode === 403 || res.statusCode === 404) {
78
- portCache.delete(projectRoot);
89
+ // Stale lockfile or rotated token? Drop cache so next call re-reads.
90
+ // SEC-14-02: invalidate on 401 too token may have rotated after
91
+ // sidecar restart; cache TTL of 5s would otherwise prolong recovery.
92
+ if (res.statusCode === 401 || res.statusCode === 403 || res.statusCode === 404) {
93
+ sidecarCache.delete(projectRoot);
79
94
  cacheTimestamps.delete(projectRoot);
80
95
  }
81
96
  resolve({ sent: false, reason: `http_${res.statusCode}` });
@@ -86,7 +101,7 @@ export async function publish(event, { projectRoot, timeoutMs = 1500 } = {}) {
86
101
  req.on('error', (err) => {
87
102
  // Most common: ECONNREFUSED (lockfile points at a dead port).
88
103
  if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET') {
89
- portCache.delete(projectRoot);
104
+ sidecarCache.delete(projectRoot);
90
105
  cacheTimestamps.delete(projectRoot);
91
106
  }
92
107
  resolve({ sent: false, reason: `error: ${err.code || err.message}` });
@@ -6,7 +6,7 @@
6
6
  // 1. process.kill(pid, 0) — ESRCH/EPERM means the holder is gone
7
7
  // 2. optional HTTP healthz probe (injected by caller; keeps this module pure of net)
8
8
 
9
- import { createHash } from 'node:crypto';
9
+ import { createHash, randomBytes } from 'node:crypto';
10
10
  import fs from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
@@ -55,6 +55,10 @@ export function acquireLock({ projectRoot, port, version, startedAt }) {
55
55
  version: version ?? null,
56
56
  startedAt: startedAt ?? Date.now(),
57
57
  lockSchema: LOCK_VERSION,
58
+ // SEC-14-02: per-process auth token. 32 random bytes hex-encoded = 64 chars.
59
+ // Required by /publish, /shutdown, /events, /state. Lifetime = process lifetime;
60
+ // not logged, not telemetered. See docs/sidecar-security.md.
61
+ token: randomBytes(32).toString('hex'),
58
62
  };
59
63
  let fd;
60
64
  try {
package/src/ui/server.js CHANGED
@@ -22,6 +22,7 @@ import { readFileSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import process from 'node:process';
25
+ import { createHash } from 'node:crypto';
25
26
 
26
27
  import { findFreePortOrThrow } from './port.js';
27
28
  import { acquireLockOrReclaim, releaseLock } from './lockfile.js';
@@ -42,19 +43,70 @@ const SSE_HEADERS = {
42
43
  'X-Accel-Buffering': 'no',
43
44
  };
44
45
 
45
- const CSP =
46
- "default-src 'self'; " +
47
- "connect-src 'self'; " +
48
- "script-src 'self' 'unsafe-inline'; " +
49
- "style-src 'self' 'unsafe-inline'; " +
50
- "img-src 'self' data:; " +
51
- "frame-ancestors 'none'";
46
+ // SEC-14-01: CSP without 'unsafe-inline' in script-src. The single inline
47
+ // <script> block in index.html is allowed via SHA-256 hash injected at boot.
48
+ // 'unsafe-inline' kept ONLY for style-src (the entire <style> block is intentional;
49
+ // CSS injection has no script execution vector with connect-src 'self').
50
+ function buildCsp(scriptHash) {
51
+ const scriptSrc = scriptHash ? `'self' ${scriptHash}` : "'self'";
52
+ return (
53
+ "default-src 'self'; " +
54
+ "connect-src 'self'; " +
55
+ `script-src ${scriptSrc}; ` +
56
+ "style-src 'self' 'unsafe-inline'; " +
57
+ "img-src 'self' data:; " +
58
+ "frame-ancestors 'none'"
59
+ );
60
+ }
61
+
62
+ // Computes the SHA-256 hash of the inline <script> block in the static HTML.
63
+ // Returns the CSP-formatted source expression: "'sha256-<base64>='".
64
+ // Returns empty string if no <script> block found (graceful — caller falls back to "'self'" alone).
65
+ function computeScriptHashFromHtml(html) {
66
+ if (typeof html !== 'string') return '';
67
+ const m = html.match(/<script>([\s\S]*?)<\/script>/);
68
+ if (!m) return '';
69
+ const hash = createHash('sha256').update(m[1], 'utf8').digest('base64');
70
+ return `'sha256-${hash}'`;
71
+ }
52
72
 
53
73
  function logErr(...args) {
54
74
  // Strict stderr discipline — never stdout (collides with MCP JSON-RPC if running in same process).
55
75
  process.stderr.write(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
56
76
  }
57
77
 
78
+ // SEC-14-02: per-process auth token. Set during start() from acquireLock result.
79
+ // Cleared on shutdown(). Never logged in full.
80
+ let authToken = null;
81
+
82
+ // requireAuth: returns true if request has a valid token via either:
83
+ // - Authorization: Bearer <token> (preferred for fetch from same-origin browser)
84
+ // - ?t=<token> query param (required for EventSource — browser API can't set headers)
85
+ // Caller is responsible for sending 401 when this returns false.
86
+ function requireAuth(req, url) {
87
+ if (!authToken) return false; // server didn't init token — fail closed
88
+ const auth = req.headers.authorization;
89
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
90
+ const provided = auth.slice('Bearer '.length).trim();
91
+ if (timingSafeEqual(provided, authToken)) return true;
92
+ }
93
+ const qp = url?.searchParams?.get('t');
94
+ if (typeof qp === 'string' && timingSafeEqual(qp, authToken)) return true;
95
+ return false;
96
+ }
97
+
98
+ // Constant-time string comparison to prevent timing-leak side channel.
99
+ // Walks the longer of the two strings even when lengths differ to keep timing flat.
100
+ function timingSafeEqual(a, b) {
101
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
102
+ const max = Math.max(a.length, b.length);
103
+ let diff = a.length ^ b.length;
104
+ for (let i = 0; i < max; i++) {
105
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
106
+ }
107
+ return diff === 0;
108
+ }
109
+
58
110
  // Validate Host header against allowed hostnames (REQ SEC-01).
59
111
  // Allow 127.0.0.1 and localhost on whatever port we're on.
60
112
  function isHostAllowed(req, port) {
@@ -122,15 +174,22 @@ function readBody(req, maxBytes = 64 * 1024) {
122
174
  });
123
175
  }
124
176
 
177
+ let _cachedIndex = null; // { html, scriptHash }
125
178
  function loadStaticIndex() {
126
179
  // src/ui/static/index.html — written in Phase 14. We tolerate it missing in
127
180
  // unit tests by serving a placeholder so the server module is testable in isolation.
181
+ if (_cachedIndex) return _cachedIndex;
182
+ let html;
128
183
  try {
129
- return readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
184
+ html = readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
130
185
  } catch {
131
- return `<!doctype html><meta charset="utf-8"><title>kit-mcp sidecar</title>
186
+ html = `<!doctype html><meta charset="utf-8"><title>kit-mcp sidecar</title>
132
187
  <body><pre>UI not yet packaged. Run \`kit ui\` after Phase 14 is shipped.</pre></body>`;
133
188
  }
189
+ // SEC-14-01: hash inline <script> for CSP whitelist. Cache per-process.
190
+ const scriptHash = computeScriptHashFromHtml(html);
191
+ _cachedIndex = { html, scriptHash };
192
+ return _cachedIndex;
134
193
  }
135
194
 
136
195
  export function createServer({
@@ -224,9 +283,14 @@ export function createServer({
224
283
  try { releaseLock(projectRoot); } catch { /* noop */ }
225
284
  lockMeta = null;
226
285
  }
286
+ authToken = null; // SEC-14-02: clear so a re-start gets a fresh one
227
287
  }
228
288
 
229
- function handleEvents(req, res) {
289
+ function handleEvents(req, res, url) {
290
+ if (!requireAuth(req, url)) {
291
+ sendJson(res, 401, { error: 'auth_required' });
292
+ return;
293
+ }
230
294
  if (subscribers.size >= maxSubscribers) {
231
295
  sendJson(res, 503, { error: 'too_many_subscribers', max: maxSubscribers });
232
296
  return;
@@ -269,7 +333,11 @@ export function createServer({
269
333
  res.on('error', cleanup);
270
334
  }
271
335
 
272
- async function handlePublish(req, res) {
336
+ async function handlePublish(req, res, url) {
337
+ if (!requireAuth(req, url)) {
338
+ sendJson(res, 401, { error: 'auth_required' });
339
+ return;
340
+ }
273
341
  if (!isOriginAllowed(req, listeningPort)) {
274
342
  sendJson(res, 403, { error: 'origin_not_allowed' });
275
343
  return;
@@ -313,7 +381,11 @@ export function createServer({
313
381
 
314
382
  // PERF-05: optional pagination via ?offset=N&limit=M. No query → ring inteiro
315
383
  // (back-compat preservada). Out-of-range values clamp to bounds rather than 4xx.
316
- function handleState(res, url) {
384
+ function handleState(req, res, url) {
385
+ if (!requireAuth(req, url)) {
386
+ sendJson(res, 401, { error: 'auth_required' });
387
+ return;
388
+ }
317
389
  let events = ring;
318
390
  const offsetRaw = url?.searchParams?.get('offset');
319
391
  const limitRaw = url?.searchParams?.get('limit');
@@ -338,7 +410,11 @@ export function createServer({
338
410
  });
339
411
  }
340
412
 
341
- async function handleShutdownRequest(req, res) {
413
+ async function handleShutdownRequest(req, res, url) {
414
+ if (!requireAuth(req, url)) {
415
+ sendJson(res, 401, { error: 'auth_required' });
416
+ return;
417
+ }
342
418
  if (!isOriginAllowed(req, listeningPort)) {
343
419
  sendJson(res, 403, { error: 'origin_not_allowed' });
344
420
  return;
@@ -350,10 +426,16 @@ export function createServer({
350
426
  }
351
427
 
352
428
  function handleIndex(res) {
353
- const html = staticHtml ?? loadStaticIndex();
429
+ let html, scriptHash;
430
+ if (typeof staticHtml === 'string') {
431
+ html = staticHtml;
432
+ scriptHash = computeScriptHashFromHtml(staticHtml);
433
+ } else {
434
+ ({ html, scriptHash } = loadStaticIndex());
435
+ }
354
436
  res.writeHead(200, {
355
437
  'Content-Type': 'text/html; charset=utf-8',
356
- 'Content-Security-Policy': CSP,
438
+ 'Content-Security-Policy': buildCsp(scriptHash),
357
439
  'X-Content-Type-Options': 'nosniff',
358
440
  'Referrer-Policy': 'no-referrer',
359
441
  });
@@ -374,15 +456,15 @@ export function createServer({
374
456
  case 'GET /index.html':
375
457
  return handleIndex(res);
376
458
  case 'GET /events':
377
- return handleEvents(req, res);
459
+ return handleEvents(req, res, url);
378
460
  case 'GET /healthz':
379
461
  return handleHealthz(res);
380
462
  case 'GET /state':
381
- return handleState(res, url);
463
+ return handleState(req, res, url);
382
464
  case 'POST /publish':
383
- return handlePublish(req, res);
465
+ return handlePublish(req, res, url);
384
466
  case 'POST /shutdown':
385
- return handleShutdownRequest(req, res);
467
+ return handleShutdownRequest(req, res, url);
386
468
  default:
387
469
  return sendJson(res, 404, { error: 'not_found', route });
388
470
  }
@@ -400,6 +482,11 @@ export function createServer({
400
482
  version,
401
483
  startedAt,
402
484
  });
485
+ // SEC-14-02: copy per-process token from lockfile into closure for requireAuth.
486
+ authToken = lockMeta.token;
487
+ if (typeof authToken !== 'string' || authToken.length !== 64) {
488
+ throw new Error('SEC-14-02: lockMeta.token missing or malformed; refusing to start');
489
+ }
403
490
  server = http.createServer(handleRequest);
404
491
  server.on('connection', (sock) => {
405
492
  activeSockets.add(sock);
@@ -449,6 +536,12 @@ export const __test = {
449
536
  MAX_SSE_SUBSCRIBERS,
450
537
  DEFAULT_IDLE_MS,
451
538
  HEARTBEAT_INTERVAL_MS,
452
- CSP,
539
+ // SEC-14-01: CSP is now built dynamically with sha256 hash of inline <script>.
540
+ // The constant CSP no longer exists; tests should use buildCsp(scriptHash).
541
+ buildCsp,
542
+ computeScriptHashFromHtml,
453
543
  EVENT_TYPES,
544
+ // SEC-14-02: timingSafeEqual exposed for unit tests; requireAuth depends on
545
+ // closure state (authToken) so end-to-end HTTP tests verify behavior.
546
+ timingSafeEqual,
454
547
  };