@luanpdd/kit-mcp 1.13.0 → 1.15.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.
Files changed (46) hide show
  1. package/README.md +4 -0
  2. package/kit/COMPATIBILITY.md +65 -0
  3. package/kit/agents/ai-mutation-tester.md +1 -11
  4. package/kit/agents/burn-rate-forecaster.md +1 -9
  5. package/kit/agents/cascading-failures-auditor.md +1 -9
  6. package/kit/agents/golden-signals-instrumenter.md +1 -11
  7. package/kit/agents/incident-investigator.md +1 -9
  8. package/kit/agents/legacy-characterizer.md +1 -11
  9. package/kit/agents/load-shedding-instrumenter.md +1 -9
  10. package/kit/agents/observability-coverage-auditor.md +1 -11
  11. package/kit/agents/observability-instrumenter.md +1 -11
  12. package/kit/agents/omm-auditor.md +1 -9
  13. package/kit/agents/payload-capture-instrumenter.md +1 -11
  14. package/kit/agents/postmortem-writer.md +1 -11
  15. package/kit/agents/prr-conductor.md +1 -11
  16. package/kit/agents/refactor-safety-auditor.md +1 -11
  17. package/kit/agents/release-pipeline-auditor.md +1 -9
  18. package/kit/agents/seam-finder.md +1 -9
  19. package/kit/agents/shotgun-surgery-detector.md +1 -11
  20. package/kit/agents/slo-engineer.md +1 -9
  21. package/kit/agents/storytelling-analyst.md +1 -11
  22. package/kit/agents/supabase-architect.md +1 -9
  23. package/kit/agents/supabase-auth-bootstrapper.md +1 -11
  24. package/kit/agents/supabase-edge-fn-writer.md +1 -11
  25. package/kit/agents/supabase-migration-writer.md +1 -9
  26. package/kit/agents/supabase-realtime-implementer.md +1 -9
  27. package/kit/agents/supabase-rls-writer.md +1 -9
  28. package/kit/agents/supabase-storage-implementer.md +1 -9
  29. package/kit/agents/toil-auditor.md +1 -11
  30. package/kit/file-manifest.json +328 -221
  31. package/kit/hooks/sidecar-tool-publisher.js +36 -14
  32. package/package.json +2 -2
  33. package/src/cli/index.js +40 -15
  34. package/src/core/error-redaction.js +76 -0
  35. package/src/core/gate-runner.js +16 -4
  36. package/src/core/manifest-verify.js +107 -0
  37. package/src/core/path-safety.js +111 -0
  38. package/src/core/reflect.js +6 -1
  39. package/src/core/replays.js +10 -1
  40. package/src/core/sync.js +13 -0
  41. package/src/mcp-server/index.js +49 -12
  42. package/src/ui/auto-spawn.js +6 -1
  43. package/src/ui/client.js +34 -19
  44. package/src/ui/lockfile.js +5 -1
  45. package/src/ui/server.js +113 -20
  46. package/src/ui/static/index.html +66 -14
@@ -20,6 +20,8 @@ import { listKit, searchKit, findItem } from '../core/kit.js';
20
20
  import { listTargets } from '../core/registry.js';
21
21
  import { syncTo, statusOf, removeFrom, summarize } from '../core/sync.js';
22
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';
23
25
  import { listGates, getGate, gatesForStage } from '../core/gates.js';
24
26
  import { runGate } from '../core/gate-runner.js';
25
27
  import { collectFailures, summarizeByAgent, writeLearnings } from '../core/failures.js';
@@ -40,6 +42,7 @@ const TOOLS = [
40
42
  kind: { type: 'string', enum: ['agent', 'command', 'skill'], description: 'For action=get' },
41
43
  name: { type: 'string', description: 'For action=get' },
42
44
  query: { type: 'string', description: 'For action=search' },
45
+ terse: { type: 'boolean', description: 'For action=list-*: omit description, return only {kind, name}. Default false (PERF-15-01).' },
43
46
  },
44
47
  required: ['action'],
45
48
  },
@@ -150,10 +153,13 @@ export const PKG_VERSION = readPkgVersion();
150
153
 
151
154
  async function handleKit(args) {
152
155
  const kit = await listKit();
156
+ // PERF-15-01: terse mode skips description payload entirely. Backward-compat:
157
+ // args.terse undefined/false preserves slim()+summarize() cap-80 behavior.
158
+ const variant = args.terse === true ? slimTerse : slim;
153
159
  switch (args.action) {
154
- case 'list-agents': return kit.agents.map(slim);
155
- case 'list-commands': return kit.commands.map(slim);
156
- case 'list-skills': return [...kit.skills, ...kit.skillsExtras].map(slim);
160
+ case 'list-agents': return kit.agents.map(variant);
161
+ case 'list-commands': return kit.commands.map(variant);
162
+ case 'list-skills': return [...kit.skills, ...kit.skillsExtras].map(variant);
157
163
  case 'get': {
158
164
  const item = findItem(kit, args.kind, args.name);
159
165
  if (!item) return { error: `Not found: ${args.kind}/${args.name}` };
@@ -192,25 +198,45 @@ async function withAutoSpawn(args, tool, run) {
192
198
  async function handleSync(args) {
193
199
  switch (args.action) {
194
200
  case 'targets': return listTargets();
195
- case 'status': return statusOf(args.target, { projectRoot: args.projectRoot });
201
+ case 'status':
196
202
  case 'install':
197
- return withAutoSpawn(args, 'sync.install', (onProgress) =>
198
- syncTo(args.target, { projectRoot: args.projectRoot, mode: args.mode, dryRun: args.dryRun, onProgress }));
199
- case 'remove': return removeFrom(args.target, { projectRoot: args.projectRoot });
203
+ case 'remove': {
204
+ // SEC-14-03: MCP message must specify a path inside a git workspace.
205
+ // CLI bypasses this bin/cli.js trusts whoever invoked it (same trust
206
+ // model as Phase 79.01's gates.run guard). status is read-only but
207
+ // included for defense-in-depth and a single uniform error surface.
208
+ const guard = await validateProjectRoot(args.projectRoot);
209
+ if (!guard.ok) return { error: guard.reason };
210
+ const projectRoot = guard.resolvedPath;
211
+ if (args.action === 'status') return statusOf(args.target, { projectRoot });
212
+ if (args.action === 'install')
213
+ return withAutoSpawn({ ...args, projectRoot }, 'sync.install', (onProgress) =>
214
+ syncTo(args.target, { projectRoot, mode: args.mode, dryRun: args.dryRun, onProgress }));
215
+ // action === 'remove'
216
+ return removeFrom(args.target, { projectRoot });
217
+ }
200
218
  default: return { error: `Unknown action: ${args.action}` };
201
219
  }
202
220
  }
203
221
 
204
222
  async function handleReverseSync(args) {
205
223
  switch (args.action) {
206
- case 'detect': return detectReverse(args.target, { projectRoot: args.projectRoot });
207
- case 'apply':
208
- return withAutoSpawn(args, 'reverse-sync.apply', (onProgress) =>
224
+ case 'detect':
225
+ case 'apply': {
226
+ // SEC-14-03: same guard as handleSync — reverse-sync apply also writes
227
+ // to disk (kit/<file>) so it must be on the same allowlist as sync.
228
+ const guard = await validateProjectRoot(args.projectRoot);
229
+ if (!guard.ok) return { error: guard.reason };
230
+ const projectRoot = guard.resolvedPath;
231
+ if (args.action === 'detect') return detectReverse(args.target, { projectRoot });
232
+ // action === 'apply'
233
+ return withAutoSpawn({ ...args, projectRoot }, 'reverse-sync.apply', (onProgress) =>
209
234
  applyReverse(args.target, {
210
- projectRoot: args.projectRoot,
235
+ projectRoot,
211
236
  strategy: args.strategy, only: args.only, dryRun: args.dryRun,
212
237
  onProgress,
213
238
  }));
239
+ }
214
240
  default: return { error: `Unknown action: ${args.action}` };
215
241
  }
216
242
  }
@@ -283,6 +309,13 @@ function slim(x) {
283
309
  return { kind: x.kind, name: x.name, description: summarize(x.description) };
284
310
  }
285
311
 
312
+ // PERF-15-01: terse variant — omits description entirely. Used when MCP client
313
+ // only needs name discovery (e.g. populating UI lists, validating slug references).
314
+ // Default action=list-* still returns description capped via slim()/summarize().
315
+ function slimTerse(x) {
316
+ return { kind: x.kind, name: x.name };
317
+ }
318
+
286
319
  // --- server bootstrap ---
287
320
 
288
321
  export async function createServer() {
@@ -303,8 +336,12 @@ export async function createServer() {
303
336
  const result = await handler(args ?? {});
304
337
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
305
338
  } catch (e) {
339
+ // SEC-14-06: full stack stays in stderr for operator debug; client envelope is sanitized.
340
+ // sanitizeMcpError redacts secrets/paths from e.message, preserves e.code (Phase 83
341
+ // EMANIFESTMISMATCH invariant), and emits NO stack field.
342
+ console.error('[mcp-server] error in handler:', e?.stack ?? e);
306
343
  return {
307
- content: [{ type: 'text', text: JSON.stringify({ error: e.message, stack: e.stack }, null, 2) }],
344
+ content: [{ type: 'text', text: JSON.stringify(sanitizeMcpError(e), null, 2) }],
308
345
  isError: true,
309
346
  };
310
347
  }
@@ -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
  };
@@ -1175,11 +1175,51 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
1175
1175
 
1176
1176
  <script>
1177
1177
  /* ──────────────────────────────────────────────────────────
1178
- kit-mcp sidecar — prototype
1179
- This is a faithful mock of what the production HTML will do.
1180
- In production, replace the MockSource with a real EventSource('/events').
1178
+ kit-mcp sidecar — production client
1179
+ SEC-14-01 (Phase 82): All event-derived content rendered via escapeHtml()
1180
+ before insertion into innerHTML. CSP without 'unsafe-inline' in script-src
1181
+ is primary defense; escapeHtml() is defense-in-depth.
1182
+ When adding new innerHTML sites, always escape dynamic fields.
1181
1183
  ────────────────────────────────────────────────────────── */
1182
1184
 
1185
+ /* ──────────────────────────────────────────────────────────
1186
+ SEC-14-02 (Phase 82 / Plan 02): auth token from URL query param.
1187
+ Server (Plan 01) requires Bearer token on /publish, /shutdown, /state
1188
+ and ?t= on /events. This block extracts ?t= once at boot, scrubs it
1189
+ from the address bar (so it doesn't leak via screen-share / browser
1190
+ history copy-paste), and exposes helpers for fetch + EventSource.
1191
+ Variable scoped to closure (not sessionStorage) — reload re-handshakes.
1192
+ ────────────────────────────────────────────────────────── */
1193
+ const __sidecarToken = (() => {
1194
+ try {
1195
+ const params = new URLSearchParams(window.location.search);
1196
+ const t = params.get("t");
1197
+ if (t && /^[0-9a-f]{64}$/.test(t)) {
1198
+ // Scrub from URL so re-share / screenshot doesn't leak it.
1199
+ params.delete("t");
1200
+ const newSearch = params.toString();
1201
+ const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
1202
+ window.history.replaceState(null, "", newUrl);
1203
+ return t;
1204
+ }
1205
+ } catch (_) { /* fall through */ }
1206
+ return null;
1207
+ })();
1208
+
1209
+ function authedFetch(input, init = {}) {
1210
+ const opts = { ...init, headers: { ...(init.headers || {}) } };
1211
+ if (__sidecarToken) {
1212
+ opts.headers["Authorization"] = "Bearer " + __sidecarToken;
1213
+ }
1214
+ return fetch(input, opts);
1215
+ }
1216
+
1217
+ function authedEventSourceUrl(path) {
1218
+ if (!__sidecarToken) return path;
1219
+ const sep = path.includes("?") ? "&" : "?";
1220
+ return path + sep + "t=" + encodeURIComponent(__sidecarToken);
1221
+ }
1222
+
1183
1223
  /* ---------- humanize helpers (preserved API) ---------- */
1184
1224
  const TYPE_LABELS = {
1185
1225
  "run.start": "INICIADO",
@@ -1433,16 +1473,21 @@ function historyRowHtml(h) {
1433
1473
  return `<div class="hist-detail-row"><span class="pct">${escapeHtml(pct)}</span><span class="lbl">${escapeHtml(lbl)}</span>${tk ? `<span class="tok">${tk}t</span>` : ""}</div>`;
1434
1474
  })
1435
1475
  .join("");
1476
+ // SEC-14-01: every dynamic field escaped before injection into the
1477
+ // history-row template. status/statusGlyph/dur/when/tokens are computed
1478
+ // locally (string enum or pre-built HTML literal) so are safe by construction;
1479
+ // h.runId and h.eventsCount cross the publisher boundary and MUST be escaped
1480
+ // even though they should normally be benign — defense in depth.
1436
1481
  return `
1437
- <div class="hist-row" data-status="${status}" data-runid="${h.runId}">
1482
+ <div class="hist-row" data-status="${escapeHtml(status)}" data-runid="${escapeHtml(h.runId)}">
1438
1483
  <div class="hist-status">${statusGlyph}</div>
1439
1484
  <div class="hist-title">${escapeHtml(title)}</div>
1440
1485
  <div class="hist-when">${when}</div>
1441
1486
  <div class="hist-meta-row">
1442
1487
  <span><span class="num">${dur}</span> dur</span>
1443
1488
  <span>${tokens}</span>
1444
- <span><span class="num">${h.eventsCount || 0}</span> eventos</span>
1445
- <span class="num">id ${h.runId.slice(0,8)}</span>
1489
+ <span><span class="num">${escapeHtml(String(h.eventsCount || 0))}</span> eventos</span>
1490
+ <span class="num">id ${escapeHtml(String(h.runId).slice(0,8))}</span>
1446
1491
  </div>
1447
1492
  <div class="hist-detail">${detailRows || '<div class="hist-detail-row"><span class="lbl">(sem progresso registrado)</span></div>'}</div>
1448
1493
  </div>
@@ -1534,10 +1579,14 @@ function activeCardHtml(run) {
1534
1579
  const longRunning = elapsed > 30_000;
1535
1580
  const stepCount = run.total ? `${run.current}/${run.total}` : "";
1536
1581
  const showTokens = run.tokens > 0;
1582
+ // SEC-14-01: family/iconHref/title/stepLabel/stepCount/percent are computed
1583
+ // locally; runId crosses the publisher boundary and is escaped before any
1584
+ // injection (data-attribute or text). Defense in depth: even string enums
1585
+ // (family) get escaped to ensure regression resistance.
1537
1586
  return `
1538
- <article class="run-card" data-runid="${run.runId}">
1587
+ <article class="run-card" data-runid="${escapeHtml(run.runId)}">
1539
1588
  <div class="rc-head">
1540
- <div class="rc-icon" data-tool="${family}"><svg><use href="${iconHref}"/></svg></div>
1589
+ <div class="rc-icon" data-tool="${escapeHtml(family)}"><svg><use href="${escapeHtml(iconHref)}"/></svg></div>
1541
1590
  <div class="rc-title-block">
1542
1591
  <div class="rc-tool">${escapeHtml(safeStr(run.tool) || "processo")}</div>
1543
1592
  <div class="rc-title">${escapeHtml(title)}</div>
@@ -1556,7 +1605,7 @@ function activeCardHtml(run) {
1556
1605
  <div class="rc-step">
1557
1606
  <span class="glyph"><svg><use href="#i-spin"/></svg></span>
1558
1607
  <span class="rc-step-text">${escapeHtml(stepLabel)}</span>
1559
- ${stepCount ? `<span class="rc-step-count">${stepCount}</span>` : ""}
1608
+ ${stepCount ? `<span class="rc-step-count">${escapeHtml(stepCount)}</span>` : ""}
1560
1609
  </div>
1561
1610
 
1562
1611
  <div class="rc-tokens" ${showTokens ? "" : "style=\"display:none\""}>
@@ -1565,7 +1614,7 @@ function activeCardHtml(run) {
1565
1614
  </div>
1566
1615
 
1567
1616
  <div class="rc-foot">
1568
- <span class="rc-runid">id ${run.runId.slice(0, 8)}</span>
1617
+ <span class="rc-runid">id ${escapeHtml(String(run.runId).slice(0, 8))}</span>
1569
1618
  <span class="sep">·</span>
1570
1619
  <span>${escapeHtml(safeStr(run.tool) || "")}</span>
1571
1620
  </div>
@@ -1708,8 +1757,11 @@ function rowHtml(evt, idx, prev) {
1708
1757
  msg = `<span class="ident">${escapeHtml(badge.toLowerCase())}</span>`;
1709
1758
  }
1710
1759
  const sourcePill = renderSourcePill(evt.payload?.source);
1760
+ // SEC-14-01: evt.type and evt.runId cross publisher boundary → escape.
1761
+ // msg/tokenChip/sourcePill are pre-built HTML literals where each interpolation
1762
+ // already used escapeHtml() — safe by construction.
1711
1763
  return `
1712
- <div class="tl-row" data-type="${evt.type}" data-ok="${ok}" data-grouped="${grouped}">
1764
+ <div class="tl-row" data-type="${escapeHtml(evt.type)}" data-ok="${ok}" data-grouped="${grouped}">
1713
1765
  <div class="tl-time" title="${time}">${rel}</div>
1714
1766
  <div class="tl-rail"><div class="tl-node"></div></div>
1715
1767
  <div class="tl-content">
@@ -1717,7 +1769,7 @@ function rowHtml(evt, idx, prev) {
1717
1769
  <span class="tl-msg">${msg}</span>
1718
1770
  ${tokenChip}
1719
1771
  ${sourcePill}
1720
- ${evt.runId ? `<span class="tl-runid">${evt.runId.slice(0,6)}</span>` : ""}
1772
+ ${evt.runId ? `<span class="tl-runid">${escapeHtml(String(evt.runId).slice(0,6))}</span>` : ""}
1721
1773
  </div>
1722
1774
  </div>
1723
1775
  `;
@@ -1917,7 +1969,7 @@ function applyConnState(s) {
1917
1969
 
1918
1970
  async function hydrateFromState() {
1919
1971
  try {
1920
- const res = await fetch("/state", { credentials: "omit" });
1972
+ const res = await authedFetch("/state", { credentials: "omit" });
1921
1973
  if (!res.ok) return;
1922
1974
  const j = await res.json();
1923
1975
  if (j.port && document.querySelector(".brand-sub")) {
@@ -1938,7 +1990,7 @@ function connectRealSource() {
1938
1990
  applyConnState("connecting");
1939
1991
  if (evtSource) try { evtSource.close(); } catch (_) {}
1940
1992
 
1941
- evtSource = new EventSource("/events");
1993
+ evtSource = new EventSource(authedEventSourceUrl("/events"));
1942
1994
 
1943
1995
  evtSource.addEventListener("open", () => {
1944
1996
  lastConnectedAt = Date.now();