@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.
- package/README.md +10 -10
- package/kit/agents/codebase-mapper.md +0 -6
- package/kit/agents/debugger.md +0 -6
- package/kit/agents/executor.md +0 -6
- package/kit/agents/phase-researcher.md +0 -6
- package/kit/agents/planner.md +0 -6
- package/kit/agents/project-researcher.md +0 -6
- package/kit/agents/research-synthesizer.md +0 -6
- package/kit/agents/roadmapper.md +0 -6
- package/kit/agents/ui-auditor.md +0 -6
- package/kit/agents/ui-researcher.md +0 -6
- package/kit/agents/verifier.md +0 -6
- package/kit/file-manifest.json +154 -48
- package/kit/hooks/check-update.js +4 -0
- package/kit/hooks/context-monitor.js +9 -2
- package/kit/hooks/post-apply-migration.js +10 -2
- package/kit/hooks/prompt-guard.js +9 -2
- package/kit/hooks/sidecar-tool-publisher.js +36 -14
- package/kit/hooks/statusline.js +6 -0
- package/kit/hooks/workflow-guard.js +9 -2
- package/package.json +1 -2
- package/src/cli/index.js +17 -5
- package/src/core/error-redaction.js +76 -0
- package/src/core/gate-runner.js +16 -4
- package/src/core/manifest-verify.js +103 -0
- package/src/core/path-safety.js +111 -0
- package/src/core/reflect.js +6 -1
- package/src/core/replays.js +59 -4
- package/src/core/sync.js +17 -2
- package/src/mcp-server/index.js +69 -18
- package/src/ui/auto-spawn.js +6 -1
- package/src/ui/client.js +34 -19
- package/src/ui/lockfile.js +5 -1
- package/src/ui/server.js +113 -20
- package/src/ui/static/index.html +66 -14
- package/CHANGELOG.md +0 -883
package/src/core/replays.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
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;
|
package/src/mcp-server/index.js
CHANGED
|
@@ -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':
|
|
197
|
+
case 'status':
|
|
175
198
|
case 'install':
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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':
|
|
186
|
-
case 'apply':
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
333
|
+
content: [{ type: 'text', text: JSON.stringify(sanitizeMcpError(e), null, 2) }],
|
|
283
334
|
isError: true,
|
|
284
335
|
};
|
|
285
336
|
}
|
package/src/ui/auto-spawn.js
CHANGED
|
@@ -97,7 +97,12 @@ export async function ensureSidecar({ projectRoot, openBrowserOnSpawn = true } =
|
|
|
97
97
|
|
|
98
98
|
let opened = false;
|
|
99
99
|
if (openBrowserOnSpawn) {
|
|
100
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
18
|
+
function readCachedSidecar(projectRoot) {
|
|
17
19
|
const ts = cacheTimestamps.get(projectRoot);
|
|
18
|
-
if (!ts || Date.now() - ts >
|
|
19
|
-
return
|
|
20
|
+
if (!ts || Date.now() - ts > SIDECAR_CACHE_TTL_MS) return undefined;
|
|
21
|
+
return sidecarCache.get(projectRoot);
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
function
|
|
23
|
-
|
|
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
|
-
|
|
32
|
+
sidecarCache.clear();
|
|
29
33
|
cacheTimestamps.clear();
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
function
|
|
33
|
-
const cached =
|
|
36
|
+
function resolveSidecar(projectRoot) {
|
|
37
|
+
const cached = readCachedSidecar(projectRoot);
|
|
34
38
|
if (cached !== undefined) return cached;
|
|
35
39
|
const lock = readLock(projectRoot);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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 =
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
104
|
+
sidecarCache.delete(projectRoot);
|
|
90
105
|
cacheTimestamps.delete(projectRoot);
|
|
91
106
|
}
|
|
92
107
|
resolve({ sent: false, reason: `error: ${err.code || err.message}` });
|
package/src/ui/lockfile.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
184
|
+
html = readFileSync(path.join(STATIC_DIR, 'index.html'), 'utf8');
|
|
130
185
|
} catch {
|
|
131
|
-
|
|
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
|
-
|
|
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':
|
|
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
|
};
|