@luanpdd/kit-mcp 1.13.0 → 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.
@@ -0,0 +1,103 @@
1
+ // SEC-14-05: verify kit/file-manifest.json against actual file contents.
2
+ // Called by syncTo() in install path, before any write — refuses to project
3
+ // a tampered kit. Opt-out via KIT_MCP_SKIP_MANIFEST_CHECK=1 (warn on stderr).
4
+ //
5
+ // Manifest format (kit/file-manifest.json):
6
+ // { version, timestamp, files: { "<rel-to-kitRoot>": "<sha256-hex>", ... } }
7
+ //
8
+ // Returns:
9
+ // { ok: true } when all listed files exist + match.
10
+ // { ok: true, skipped: true } when KIT_MCP_SKIP_MANIFEST_CHECK=1.
11
+ // { ok: false, reason, mismatches, missing } otherwise.
12
+
13
+ import path from 'node:path';
14
+ import fs from 'node:fs/promises';
15
+ import crypto from 'node:crypto';
16
+
17
+ const SKIP_ENV = 'KIT_MCP_SKIP_MANIFEST_CHECK';
18
+
19
+ export async function verifyManifest(kitRoot) {
20
+ if (process.env[SKIP_ENV] === '1') {
21
+ process.stderr.write(
22
+ '[kit-mcp] WARNING: ' + SKIP_ENV + '=1 set — skipping kit/file-manifest.json verification (dev mode).\n'
23
+ );
24
+ return { ok: true, skipped: true };
25
+ }
26
+
27
+ const manifestPath = path.join(kitRoot, 'file-manifest.json');
28
+ let manifest;
29
+ try {
30
+ const raw = await fs.readFile(manifestPath, 'utf8');
31
+ manifest = JSON.parse(raw);
32
+ } catch (e) {
33
+ return {
34
+ ok: false,
35
+ reason: 'kit manifest unreadable at ' + manifestPath + ': ' + e.message,
36
+ mismatches: [],
37
+ missing: [],
38
+ };
39
+ }
40
+
41
+ if (!manifest.files || typeof manifest.files !== 'object') {
42
+ return {
43
+ ok: false,
44
+ reason: "kit manifest malformed at " + manifestPath + ": missing 'files' object",
45
+ mismatches: [],
46
+ missing: [],
47
+ };
48
+ }
49
+
50
+ const mismatches = [];
51
+ const missing = [];
52
+
53
+ for (const [rel, expected] of Object.entries(manifest.files)) {
54
+ const abs = path.join(kitRoot, rel);
55
+ let buf;
56
+ try {
57
+ buf = await fs.readFile(abs);
58
+ } catch {
59
+ missing.push(rel);
60
+ continue;
61
+ }
62
+ const actual = crypto.createHash('sha256').update(buf).digest('hex');
63
+ if (actual !== expected) {
64
+ mismatches.push({ path: rel, expected: expected.slice(0, 16), actual: actual.slice(0, 16) });
65
+ }
66
+ }
67
+
68
+ if (mismatches.length === 0 && missing.length === 0) {
69
+ return { ok: true };
70
+ }
71
+
72
+ // Build a concise reason — first 3 mismatches, plus counts.
73
+ const sample = mismatches
74
+ .slice(0, 3)
75
+ .map((m) => m.path + ' (expected ' + m.expected + ', got ' + m.actual + ')')
76
+ .join('; ');
77
+ const missingSample = missing.slice(0, 3).join(', ');
78
+ const reasonParts = [];
79
+ if (mismatches.length > 0) {
80
+ reasonParts.push(
81
+ mismatches.length +
82
+ ' file(s) tampered: ' +
83
+ sample +
84
+ (mismatches.length > 3 ? ', +' + (mismatches.length - 3) + ' more' : '')
85
+ );
86
+ }
87
+ if (missing.length > 0) {
88
+ reasonParts.push(
89
+ missing.length +
90
+ ' file(s) missing: ' +
91
+ missingSample +
92
+ (missing.length > 3 ? ', +' + (missing.length - 3) + ' more' : '')
93
+ );
94
+ }
95
+ reasonParts.push('set ' + SKIP_ENV + '=1 to bypass (dev only)');
96
+
97
+ return {
98
+ ok: false,
99
+ reason: 'kit manifest mismatch — ' + reasonParts.join('; '),
100
+ mismatches,
101
+ missing,
102
+ };
103
+ }
@@ -0,0 +1,111 @@
1
+ // SEC-14-03: validate that a projectRoot supplied via MCP message points to a
2
+ // real git workspace before any handler that writes to disk dispatches into
3
+ // sync.js / reverse-sync.js.
4
+ //
5
+ // The helper is intentionally pure (no throw): MCP handlers package errors as
6
+ // `{ error: <string> }` envelopes (see src/mcp-server/index.js handleSync,
7
+ // handleGates, handleForensics — all use the same shape). Returning a discriminated
8
+ // `{ ok, ...}` lets each caller decide between an envelope error or a CLI exit
9
+ // without try/catch boilerplate.
10
+ //
11
+ // Why a directory-existence + walk-up `.git/` check (and not, say, spawning
12
+ // `git rev-parse --show-toplevel`):
13
+ // - Heuristic is good enough for our threat model. The attacker we are blocking
14
+ // is "MCP message says projectRoot=\\evil-host\share or %APPDATA%". Both fail
15
+ // the existence-or-`.git`-ancestor test trivially.
16
+ // - No child_process means no dependency on `git` being on PATH at runtime, no
17
+ // spawn latency on the hot path of every tool call, and no risk of the spawned
18
+ // git itself reading config from an attacker-influenced cwd.
19
+ // - The walk-up loop is bounded — Windows roots terminate at `D:\`, POSIX at
20
+ // `/`, and `path.dirname(cur) === cur` is the universal fixed point. Typical
21
+ // workspaces have <8 levels to a `.git/`, so a stat per level is fine.
22
+ //
23
+ // CLI does NOT call this — `bin/cli.js` trusts whoever invoked it (same trust
24
+ // model as Phase 79.01's gates.run guard).
25
+
26
+ import path from 'node:path';
27
+ import fs from 'node:fs/promises';
28
+
29
+ // All rejection reasons embed the literal "git workspace" — MCP clients (and
30
+ // our own regression tests) match on that single sentinel regardless of which
31
+ // check fired. Keeping the wording uniform means callers don't have to maintain
32
+ // six regexes; one suffices.
33
+ const SENTINEL = 'MCP sync requires projectRoot to be a git workspace';
34
+
35
+ export async function validateProjectRoot(projectRoot) {
36
+ // Reject empty / nullish up-front. We require an explicit projectRoot from
37
+ // MCP messages — falling back to `process.cwd()` of the MCP server would let
38
+ // an attacker probe wherever the server happened to be launched.
39
+ if (projectRoot === undefined || projectRoot === null || projectRoot === '') {
40
+ return {
41
+ ok: false,
42
+ reason: SENTINEL + '; got <empty> (pass an absolute path to a git workspace)',
43
+ };
44
+ }
45
+ if (typeof projectRoot !== 'string') {
46
+ return {
47
+ ok: false,
48
+ reason: SENTINEL + '; got non-string projectRoot of type ' + typeof projectRoot,
49
+ };
50
+ }
51
+
52
+ // path.resolve normalises separators and collapses `..` segments so a later
53
+ // attacker payload like `C:\Users\\..\evil` is reduced before the existence
54
+ // check happens. resolve() is also a no-op on already-absolute paths.
55
+ const resolved = path.resolve(projectRoot);
56
+
57
+ // Defensive — path.resolve should always return absolute, but if a future
58
+ // Node version changes that we still want to reject.
59
+ if (!path.isAbsolute(resolved)) {
60
+ return {
61
+ ok: false,
62
+ reason: SENTINEL + '; projectRoot did not resolve to an absolute path: ' + projectRoot,
63
+ };
64
+ }
65
+
66
+ // The stat doubles as an existence + reachability check. UNC paths to
67
+ // unreachable hosts (`\\evil-host\share`) reject here on Windows with ENOENT
68
+ // / EHOSTUNREACH within milliseconds; Node treats both as a rejection so we
69
+ // never proceed to write a single byte.
70
+ let stat;
71
+ try {
72
+ stat = await fs.stat(resolved);
73
+ } catch {
74
+ return {
75
+ ok: false,
76
+ reason: SENTINEL + '; projectRoot does not exist or is unreachable: ' + resolved,
77
+ };
78
+ }
79
+
80
+ if (!stat.isDirectory()) {
81
+ return {
82
+ ok: false,
83
+ reason: SENTINEL + '; projectRoot must be a directory: ' + resolved,
84
+ };
85
+ }
86
+
87
+ // Walk up looking for `.git` (file or directory — `git worktree` uses a file).
88
+ // Bounded by the dirname fixed-point check so this terminates on every OS.
89
+ let cur = resolved;
90
+ // eslint-disable-next-line no-constant-condition
91
+ while (true) {
92
+ try {
93
+ await fs.stat(path.join(cur, '.git'));
94
+ return { ok: true, resolvedPath: resolved };
95
+ } catch {
96
+ // not here — keep walking up
97
+ }
98
+ const parent = path.dirname(cur);
99
+ if (parent === cur) break;
100
+ cur = parent;
101
+ }
102
+
103
+ // No .git/ found anywhere in the chain — the canonical reject. The literal
104
+ // "git workspace" string is part of the public contract — tests
105
+ // (test/unit/mcp-projectroot-guard.test.js) and downstream MCP clients match
106
+ // on it. Don't rephrase without coordinating callers.
107
+ return {
108
+ ok: false,
109
+ reason: SENTINEL + '; got ' + projectRoot,
110
+ };
111
+ }
@@ -19,6 +19,7 @@ import fs from 'node:fs/promises';
19
19
  import { createInterface } from 'node:readline/promises';
20
20
  import { stdin as input, stdout as output, stderr } from 'node:process';
21
21
  import { resolveKitRoot } from './kit.js';
22
+ import { redactSecrets } from './error-redaction.js';
22
23
 
23
24
  const DEFAULT_MODEL = process.env.KIT_REFLECT_MODEL ?? 'claude-sonnet-4-5-20250929';
24
25
  const DEFAULT_MAX_TOKENS = parseInt(process.env.KIT_REFLECT_MAX_TOKENS ?? '8000', 10);
@@ -169,7 +170,11 @@ async function callClaude(prompt) {
169
170
  });
170
171
  if (!res.ok) {
171
172
  const errBody = await res.text();
172
- throw new Error(`Anthropic API ${res.status}: ${errBody}`);
173
+ // SEC-14-06: Anthropic error responses can echo the supplied API key
174
+ // (rare but observed in 401s). Strip secrets/paths before propagating
175
+ // to caller — the central MCP catch will sanitize again, but doing it
176
+ // here means CLI callers (which bypass the MCP catch) are also protected.
177
+ throw new Error(`Anthropic API ${res.status}: ${redactSecrets(errBody)}`);
173
178
  }
174
179
  const j = await res.json();
175
180
  return {
@@ -14,6 +14,7 @@
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
 
@@ -68,7 +69,15 @@ export async function recordReplay(payload, opts = {}) {
68
69
  assertPathInside(file, dir);
69
70
 
70
71
  const record = { id, recorded_at: new Date().toISOString(), ...payload };
71
- 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');
72
81
  return { id, file, record };
73
82
  }
74
83
 
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
@@ -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';
@@ -192,25 +194,45 @@ async function withAutoSpawn(args, tool, run) {
192
194
  async function handleSync(args) {
193
195
  switch (args.action) {
194
196
  case 'targets': return listTargets();
195
- case 'status': return statusOf(args.target, { projectRoot: args.projectRoot });
197
+ case 'status':
196
198
  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 });
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
+ }
200
214
  default: return { error: `Unknown action: ${args.action}` };
201
215
  }
202
216
  }
203
217
 
204
218
  async function handleReverseSync(args) {
205
219
  switch (args.action) {
206
- case 'detect': return detectReverse(args.target, { projectRoot: args.projectRoot });
207
- case 'apply':
208
- 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) =>
209
230
  applyReverse(args.target, {
210
- projectRoot: args.projectRoot,
231
+ projectRoot,
211
232
  strategy: args.strategy, only: args.only, dryRun: args.dryRun,
212
233
  onProgress,
213
234
  }));
235
+ }
214
236
  default: return { error: `Unknown action: ${args.action}` };
215
237
  }
216
238
  }
@@ -303,8 +325,12 @@ export async function createServer() {
303
325
  const result = await handler(args ?? {});
304
326
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
305
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);
306
332
  return {
307
- 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) }],
308
334
  isError: true,
309
335
  };
310
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 {