@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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // hook-version: 1.6.1
2
+ // hook-version: 1.14.0
3
3
  // kit-mcp · Sidecar Tool Publisher (PostToolUse)
4
4
  //
5
5
  // Publishes every Claude Code tool invocation to the kit-mcp sidecar so the
@@ -52,12 +52,13 @@ process.stdin.on('end', () => {
52
52
  // kit-mcp-ui-*.lock files in tmpdir and pick one that healthz-responds.
53
53
  // This makes the hook resilient to projectRoot mismatch (case, separators,
54
54
  // trailing slash, parent-of-project edits, etc).
55
- let port = readSidecarPort(projectRoot);
56
- if (!port) port = scanAnyRunningSidecar();
57
- if (!port) {
55
+ let sidecar = readSidecarLock(projectRoot);
56
+ if (!sidecar) sidecar = scanAnyRunningSidecar();
57
+ if (!sidecar) {
58
58
  debugLog({ phase: 'no_sidecar', projectRoot });
59
59
  process.exit(0);
60
60
  }
61
+ const { port, token } = sidecar;
61
62
 
62
63
  const payload = {
63
64
  tool: toolName,
@@ -74,29 +75,34 @@ process.stdin.on('end', () => {
74
75
  payload,
75
76
  };
76
77
 
77
- publish(port, event).then(() => process.exit(0));
78
+ publish(port, token, event).then(() => process.exit(0));
78
79
  } catch (err) {
79
80
  process.stderr.write(`[sidecar-tool-publisher] ${err.message}\n`);
80
81
  process.exit(0);
81
82
  }
82
83
  });
83
84
 
84
- function readSidecarPort(projectRoot) {
85
+ function readSidecarLock(projectRoot) {
85
86
  // Mirror src/ui/lockfile.js#lockPathFor (sha1(projectRoot).slice(0,16))
86
87
  try {
87
88
  const hash = crypto.createHash('sha1').update(projectRoot).digest('hex').slice(0, 16);
88
89
  const lockPath = path.join(os.tmpdir(), `kit-mcp-ui-${hash}.lock`);
89
90
  const raw = fs.readFileSync(lockPath, 'utf8');
90
91
  const lock = JSON.parse(raw);
91
- return typeof lock.port === 'number' ? lock.port : null;
92
+ if (typeof lock.port !== 'number') return null;
93
+ return {
94
+ port: lock.port,
95
+ // SEC-14-02 (kit-mcp v1.14+): null for sidecars from v1.13 and earlier.
96
+ token: typeof lock.token === 'string' && /^[0-9a-f]{64}$/.test(lock.token) ? lock.token : null,
97
+ };
92
98
  } catch {
93
99
  return null;
94
100
  }
95
101
  }
96
102
 
97
- // Scan os.tmpdir() for any kit-mcp-ui-*.lock and return the first valid port.
98
- // Used as a fallback when projectRoot doesn't match any known lockfile (case
99
- // variants, separator differences, parent-dir edits, etc).
103
+ // Scan os.tmpdir() for any kit-mcp-ui-*.lock and return the first { port, token }
104
+ // of a live sidecar. Used as a fallback when projectRoot doesn't match any
105
+ // known lockfile (case variants, separator differences, parent-dir edits, etc).
100
106
  function scanAnyRunningSidecar() {
101
107
  try {
102
108
  const dir = os.tmpdir();
@@ -107,8 +113,16 @@ function scanAnyRunningSidecar() {
107
113
  const raw = fs.readFileSync(path.join(dir, name), 'utf8');
108
114
  const lock = JSON.parse(raw);
109
115
  if (typeof lock.port === 'number' && typeof lock.pid === 'number') {
110
- // Best-effort liveness check.
111
- try { process.kill(lock.pid, 0); return lock.port; } catch { /* dead */ }
116
+ try {
117
+ process.kill(lock.pid, 0);
118
+ // SEC-14-02: return token from same lockfile so cross-project
119
+ // publishing can authenticate. If token missing (older sidecar),
120
+ // returns null → publish degrades to 401 silent-fail.
121
+ return {
122
+ port: lock.port,
123
+ token: typeof lock.token === 'string' && /^[0-9a-f]{64}$/.test(lock.token) ? lock.token : null,
124
+ };
125
+ } catch { /* dead */ }
112
126
  }
113
127
  } catch { /* skip unreadable */ }
114
128
  }
@@ -158,7 +172,7 @@ function detectIde() {
158
172
  return 'unknown';
159
173
  }
160
174
 
161
- function publish(port, event) {
175
+ function publish(port, token, event) {
162
176
  return new Promise((resolve) => {
163
177
  const body = JSON.stringify(event);
164
178
  const req = http.request({
@@ -173,9 +187,17 @@ function publish(port, event) {
173
187
  'content-length': Buffer.byteLength(body, 'utf8'),
174
188
  origin: `http://127.0.0.1:${port}`,
175
189
  connection: 'close',
190
+ // SEC-14-02: token is null for sidecars from v1.13 and earlier; in that
191
+ // case we omit the header and the server returns 401, which the hook
192
+ // silent-fails on (matching pre-existing soft-fail discipline). A
193
+ // shipped hook v1.14 talking to a still-running sidecar v1.13 just
194
+ // loses the event — acceptable trade-off.
195
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
176
196
  },
177
197
  }, (res) => {
178
- // Drain response body to ensure server has fully processed before resolve
198
+ // Drain response body to ensure server has fully processed before resolve.
199
+ // v1.12.1 fix: await BOTH 'end' and 'close' to avoid premature exit before
200
+ // sidecar publishes via SSE. Preserve that pattern here.
179
201
  res.resume();
180
202
  res.on('end', resolve);
181
203
  res.on('close', resolve);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,7 @@
44
44
  "test": "node test/run.mjs test/unit",
45
45
  "test:integration": "node test/run.mjs test/integration",
46
46
  "test:all": "node test/run.mjs test",
47
- "prepublishOnly": "node test/run.mjs test/unit && node test/run.mjs test/integration"
47
+ "prepublishOnly": "node scripts/regen-manifest.js && node scripts/update-readme-counts.js && node test/run.mjs test/unit && node test/run.mjs test/integration"
48
48
  },
49
49
  "dependencies": {
50
50
  "@inquirer/prompts": "^8.4.2",
package/src/cli/index.js CHANGED
@@ -154,20 +154,36 @@ function slim(x) {
154
154
  return { kind: x.kind, name: x.name, description: summarize(x.description) };
155
155
  }
156
156
 
157
+ // PERF-15-01: terse variant — paridade com mcp-server slimTerse. CLI flag --terse
158
+ // controla seleção. Mantém o mesmo shape {kind, name} para programmatic consumers
159
+ // que parseiam --json output (consistência cross-surface).
160
+ function slimTerse(x) {
161
+ return { kind: x.kind, name: x.name };
162
+ }
163
+
157
164
  // --- kit ---
158
165
  const kit = program.command('kit').description('Browse the canonical kit.');
159
- kit.command('list-agents').action(async () => {
160
- const k = await withSpinner('Loading kit...', () => listKit());
161
- out(k.agents.map(slim), v => render.renderKitList(v, 'agent'));
162
- });
163
- kit.command('list-commands').action(async () => {
164
- const k = await withSpinner('Loading kit...', () => listKit());
165
- out(k.commands.map(slim), v => render.renderKitList(v, 'command'));
166
- });
167
- kit.command('list-skills').action(async () => {
168
- const k = await withSpinner('Loading kit...', () => listKit());
169
- out([...k.skills, ...k.skillsExtras].map(slim), v => render.renderKitList(v, 'skill'));
170
- });
166
+ kit.command('list-agents')
167
+ .option('--terse', 'Omit description; return only {kind, name} (PERF-15-01)')
168
+ .action(async (opts) => {
169
+ const k = await withSpinner('Loading kit...', () => listKit());
170
+ const variant = opts.terse ? slimTerse : slim;
171
+ out(k.agents.map(variant), v => render.renderKitList(v, 'agent'));
172
+ });
173
+ kit.command('list-commands')
174
+ .option('--terse', 'Omit description; return only {kind, name} (PERF-15-01)')
175
+ .action(async (opts) => {
176
+ const k = await withSpinner('Loading kit...', () => listKit());
177
+ const variant = opts.terse ? slimTerse : slim;
178
+ out(k.commands.map(variant), v => render.renderKitList(v, 'command'));
179
+ });
180
+ kit.command('list-skills')
181
+ .option('--terse', 'Omit description; return only {kind, name} (PERF-15-01)')
182
+ .action(async (opts) => {
183
+ const k = await withSpinner('Loading kit...', () => listKit());
184
+ const variant = opts.terse ? slimTerse : slim;
185
+ out([...k.skills, ...k.skillsExtras].map(variant), v => render.renderKitList(v, 'skill'));
186
+ });
171
187
  kit.command('get <kind> <name>').action(async (kind, name) => {
172
188
  const k = await listKit();
173
189
  const item = findItem(k, kind, name);
@@ -429,7 +445,7 @@ ui.command('stop')
429
445
  const lock = readLock(projectRoot);
430
446
  if (!lock) return out({ ok: false, reason: 'no_sidecar' }, () => `${icons.warn} no sidecar running for this project\n`);
431
447
  try {
432
- await postShutdown(lock.port);
448
+ await postShutdown(lock.port, lock.token);
433
449
  out({ ok: true, port: lock.port }, () => `${icons.check} sidecar at port ${lock.port} stopped\n`);
434
450
  } catch (err) {
435
451
  fail(`could not stop sidecar at port ${lock.port}: ${err.message}`);
@@ -640,15 +656,24 @@ async function runDoctorChecks(projectRoot) {
640
656
  }
641
657
 
642
658
  // Helpers for kit ui (live in cli/ — stdout/console allowed here)
643
- async function postShutdown(port) {
659
+ // SEC-14-02: /shutdown now requires Authorization Bearer <token>. Caller must
660
+ // pass the per-process token read from the lockfile (lock.token from readLock).
661
+ async function postShutdown(port, token) {
644
662
  return new Promise((resolve, reject) => {
663
+ const headers = {
664
+ host: `127.0.0.1:${port}`,
665
+ origin: `http://127.0.0.1:${port}`,
666
+ 'content-length': 0,
667
+ connection: 'close',
668
+ };
669
+ if (token) headers.authorization = `Bearer ${token}`;
645
670
  const req = http.request({
646
671
  method: 'POST',
647
672
  host: '127.0.0.1',
648
673
  port,
649
674
  path: '/shutdown',
650
675
  agent: false,
651
- headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'content-length': 0, connection: 'close' },
676
+ headers,
652
677
  }, (res) => {
653
678
  res.resume();
654
679
  res.on('end', () => res.statusCode < 400 ? resolve() : reject(new Error(`http_${res.statusCode}`)));
@@ -0,0 +1,76 @@
1
+ // SEC-14-06 — central redaction helpers shared by mcp-server, reflect, and replays.
2
+ //
3
+ // Pure module: no I/O, no globals other than the constant regex set.
4
+ //
5
+ // Why a single choke point: the threat model is "leakage of API keys, Bearer
6
+ // tokens, and absolute filesystem paths through MCP error envelopes / persisted
7
+ // replays". Scattering redaction across each call site invites drift. One file,
8
+ // one regex set, three import sites — and a single grep proves coverage.
9
+ //
10
+ // Order rationale (PATTERNS array):
11
+ // 1. sk-ant-* before sk-* — Anthropic prefix is more specific. (In practice
12
+ // the openai pattern's [A-Za-z0-9] character class would NOT swallow
13
+ // "sk-ant-" because of the dash, but ordering keeps intent legible.)
14
+ // 2. x-api-key header before Bearer — both are distinct shapes; order is
15
+ // arbitrary but stable.
16
+ // 3. Path patterns last — broadest character class, matched after specific
17
+ // secrets so a secret that contains slash-like characters has been
18
+ // stripped already.
19
+ //
20
+ // Non-false-positive contract (verified by test/unit/error-redaction.test.js):
21
+ // - "Compare A:B" stays unchanged (no `\` or `/` after `:`)
22
+ // - "Modal: hello" stays unchanged (no `\` or `/` after `:`)
23
+ // - "Visit https://example.com/path" stays (lowercase scheme, no Drive: pattern)
24
+ // - "Bearer x" stays unchanged (1 char, below 20 minimum)
25
+ // - "sk-foo" stays unchanged (3 chars after sk-, below 20 minimum)
26
+ // - "see /etc/passwd" stays unchanged (etc not in {home,Users,root} allowlist)
27
+ //
28
+ // Idempotency: redactSecrets(redactSecrets(x)) === redactSecrets(x). The
29
+ // substitution strings ('[REDACTED:*]', '[PATH]', etc.) contain no characters
30
+ // that match any of the patterns themselves.
31
+
32
+ const PATTERNS = [
33
+ { re: /sk-ant-[A-Za-z0-9_\-]{20,}/g, sub: '[REDACTED:anthropic_key]' },
34
+ { re: /sk-[A-Za-z0-9]{20,}/g, sub: '[REDACTED:openai_key]' },
35
+ { re: /x-api-key\s*:\s*[^\s,;'"]+/gi, sub: 'x-api-key: [REDACTED]' },
36
+ { re: /Bearer\s+[A-Za-z0-9._\-]{20,}/gi, sub: 'Bearer [REDACTED]' },
37
+ { re: /[A-Z]:[\\\/][^\s'"`<>]+/g, sub: '[PATH]' },
38
+ { re: /\/(home|Users|root)\/[^\s'"`<>]+/g, sub: '[PATH]' },
39
+ ];
40
+
41
+ /**
42
+ * Strip secrets and absolute filesystem paths from a string. Defensive: coerces
43
+ * non-string inputs via String(value); null/undefined return ''.
44
+ *
45
+ * @param {unknown} text
46
+ * @returns {string}
47
+ */
48
+ export function redactSecrets(text) {
49
+ if (text == null) return '';
50
+ let s = String(text);
51
+ for (const { re, sub } of PATTERNS) {
52
+ s = s.replace(re, sub);
53
+ }
54
+ return s;
55
+ }
56
+
57
+ /**
58
+ * Build the public MCP error envelope for an arbitrary thrown value. The
59
+ * server-side stderr keeps the full trace for operator debugging; the
60
+ * JSON-RPC client receives only `{error, code}` — no trace field is emitted.
61
+ *
62
+ * Preserves err.code when present (Phase 83.03 added `EMANIFESTMISMATCH`;
63
+ * downstream callers can keep dispatching on that code).
64
+ *
65
+ * @param {unknown} err
66
+ * @returns {{ error: string, code: string }}
67
+ */
68
+ export function sanitizeMcpError(err) {
69
+ const msg = err && typeof err === 'object' && 'message' in err
70
+ ? err.message
71
+ : err;
72
+ return {
73
+ error: redactSecrets(msg ?? 'unknown error'),
74
+ code: (err && typeof err === 'object' && err.code) ? err.code : 'MCP_INTERNAL_ERROR',
75
+ };
76
+ }
@@ -132,9 +132,17 @@ function extractCodeBlocks(text) {
132
132
  // --- exec ---
133
133
 
134
134
  async function execScript(script, cwd) {
135
- // Write to a temp file and run with bash. We don't try to inline -c because
136
- // the scripts can be multiline and contain quoting we'd have to escape.
137
- const tmp = path.join(os.tmpdir(), `kit-gate-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`);
135
+ // SEC-14-04: use mkdtemp for crypto-safe random directory naming, write the
136
+ // script INSIDE it, then cleanup recursive. Predictable timestamp+rand-suffix
137
+ // filenames are unsafe in multi-user /tmp attacker can pre-create a symlink
138
+ // at the predicted path before fs.writeFile, and `spawn(bash, [tmp])` would
139
+ // execute the symlink target. mkdtemp uses the OS-level mkdtemp(3) syscall
140
+ // (POSIX) / equivalent (Windows) which atomically creates a directory with
141
+ // a random suffix and returns the actual path. The new dir gets 0700 from
142
+ // process umask on POSIX (umask 022 → 0700; default Node runtime). Even if
143
+ // umask is permissive, the script file inside is written with mode 0o700.
144
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kit-gate-'));
145
+ const tmp = path.join(dir, 'gate.sh');
138
146
  await fs.writeFile(tmp, script, { encoding: 'utf8', mode: 0o700 });
139
147
  try {
140
148
  const child = spawn('bash', [tmp], { cwd, env: process.env });
@@ -151,7 +159,11 @@ async function execScript(script, cwd) {
151
159
  stderr: Buffer.concat(stderrOut).toString('utf8'),
152
160
  };
153
161
  } finally {
154
- await fs.unlink(tmp).catch(() => {});
162
+ // Recursive cleanup — even if spawn errored above, the dir gets removed.
163
+ // force:true swallows ENOENT (e.g. if script self-deleted). recursive:true
164
+ // walks the dir; even if the gate body wrote temp files inside cwd, cwd is
165
+ // separate from `dir` so we won't blast user files.
166
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
155
167
  }
156
168
  }
157
169
 
@@ -0,0 +1,107 @@
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
+ // Normalize CRLF→LF before hashing so manifest is platform-stable.
63
+ // git checkout converts EOL on Windows but Linux CI checks out LF —
64
+ // hashing raw bytes would diverge across platforms.
65
+ const normalized = Buffer.from(buf.toString('binary').replace(/\r\n/g, '\n'), 'binary');
66
+ const actual = crypto.createHash('sha256').update(normalized).digest('hex');
67
+ if (actual !== expected) {
68
+ mismatches.push({ path: rel, expected: expected.slice(0, 16), actual: actual.slice(0, 16) });
69
+ }
70
+ }
71
+
72
+ if (mismatches.length === 0 && missing.length === 0) {
73
+ return { ok: true };
74
+ }
75
+
76
+ // Build a concise reason — first 3 mismatches, plus counts.
77
+ const sample = mismatches
78
+ .slice(0, 3)
79
+ .map((m) => m.path + ' (expected ' + m.expected + ', got ' + m.actual + ')')
80
+ .join('; ');
81
+ const missingSample = missing.slice(0, 3).join(', ');
82
+ const reasonParts = [];
83
+ if (mismatches.length > 0) {
84
+ reasonParts.push(
85
+ mismatches.length +
86
+ ' file(s) tampered: ' +
87
+ sample +
88
+ (mismatches.length > 3 ? ', +' + (mismatches.length - 3) + ' more' : '')
89
+ );
90
+ }
91
+ if (missing.length > 0) {
92
+ reasonParts.push(
93
+ missing.length +
94
+ ' file(s) missing: ' +
95
+ missingSample +
96
+ (missing.length > 3 ? ', +' + (missing.length - 3) + ' more' : '')
97
+ );
98
+ }
99
+ reasonParts.push('set ' + SKIP_ENV + '=1 to bypass (dev only)');
100
+
101
+ return {
102
+ ok: false,
103
+ reason: 'kit manifest mismatch — ' + reasonParts.join('; '),
104
+ mismatches,
105
+ missing,
106
+ };
107
+ }
@@ -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