@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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hook-version: 1.4.
|
|
2
|
+
// hook-version: 1.4.1
|
|
3
|
+
// SEC-13-05: flush-before-exit category = A (stderr.write + immediate exit)
|
|
4
|
+
// Fix applied: process.stderr.write(summary, () => process.exit(0)) on success path.
|
|
3
5
|
// kit-mcp · Post-apply Migration Hook (PostToolUse)
|
|
4
6
|
//
|
|
5
7
|
// Triggers automatically AFTER a successful Supabase MCP apply_migration call.
|
|
@@ -104,12 +106,18 @@ process.stdin.on('end', () => {
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
// The final advisory printed back to Claude (and to the user via stderr)
|
|
109
|
+
// SEC-13-05: aguardar flush do stderr antes do exit. Sem callback, o
|
|
110
|
+
// resumo final pode ser dropado em pipes lentos (CI/Windows). Os outros
|
|
111
|
+
// process.stderr.write intermediários (linhas ~71/87/93/100) NÃO precisam
|
|
112
|
+
// do callback porque o process continua executando após eles — o event
|
|
113
|
+
// loop drena o buffer naturalmente antes do próximo write.
|
|
107
114
|
if (mirroredPath || stubPath) {
|
|
108
115
|
const lines = ['[post-apply-migration] resumo:'];
|
|
109
116
|
if (mirroredPath) lines.push(` • SQL: ${path.relative(projectRoot, mirroredPath)}`);
|
|
110
117
|
if (stubPath) lines.push(` • Stub: ${path.relative(vault, stubPath)}`);
|
|
111
118
|
lines.push(' → cofre Obsidian: edite o stub e commite quando puder.');
|
|
112
|
-
process.stderr.write(lines.join('\n') + '\n');
|
|
119
|
+
process.stderr.write(lines.join('\n') + '\n', () => process.exit(0));
|
|
120
|
+
return;
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
process.exit(0);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hook-version: 1.30.
|
|
2
|
+
// hook-version: 1.30.1
|
|
3
|
+
// SEC-13-05: flush-before-exit category = A (stdout.write + immediate exit)
|
|
4
|
+
// Fix applied: process.stdout.write(payload, () => process.exit(0)) on warning path.
|
|
3
5
|
// framework Prompt Injection Guard — PreToolUse hook
|
|
4
6
|
// Scans file content being written to .planning/ for prompt injection patterns.
|
|
5
7
|
// Defense-in-depth: catches injected instructions before they enter agent context.
|
|
@@ -88,7 +90,12 @@ process.stdin.on('end', () => {
|
|
|
88
90
|
},
|
|
89
91
|
};
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
// SEC-13-05: aguardar flush do stdout antes do exit. Sem callback, em
|
|
94
|
+
// pipes lentos (CI/Windows/Git Bash) o JSON pode ser dropado quando o
|
|
95
|
+
// process termina antes do kernel drenar o buffer.
|
|
96
|
+
process.stdout.write(JSON.stringify(output), () => {
|
|
97
|
+
process.exit(0);
|
|
98
|
+
});
|
|
92
99
|
} catch {
|
|
93
100
|
// Silent fail — never block tool execution
|
|
94
101
|
process.exit(0);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hook-version: 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
|
|
56
|
-
if (!
|
|
57
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
|
98
|
-
// Used as a fallback when projectRoot doesn't match any
|
|
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
|
-
|
|
111
|
-
|
|
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/kit/hooks/statusline.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// hook-version: 1.30.0
|
|
3
|
+
// SEC-13-05: flush-before-exit category = C (no process.exit, natural termination flushes) — no fix needed
|
|
3
4
|
// Claude Code Statusline - Edition
|
|
4
5
|
// Shows: model | current task | directory | context usage
|
|
5
6
|
|
|
@@ -107,6 +108,11 @@ process.stdin.on('end', () => {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
// Output
|
|
111
|
+
// SEC-13-05: statusline termina naturalmente após este write — Node
|
|
112
|
+
// garante o flush antes do process exit quando não há process.exit
|
|
113
|
+
// explícito. NÃO converter para process.stdout.write(x, callback) +
|
|
114
|
+
// process.exit() — isso introduziria um early-exit que poderia
|
|
115
|
+
// truncar saída em casos onde o write é maior que o buffer do pipe.
|
|
110
116
|
const dirname = path.basename(dir);
|
|
111
117
|
if (task) {
|
|
112
118
|
process.stdout.write(`${updateNotice}\x1b[2m${model}\x1b[0m │ \x1b[1m${task}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}`);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hook-version: 1.30.
|
|
2
|
+
// hook-version: 1.30.1
|
|
3
|
+
// SEC-13-05: flush-before-exit category = A (stdout.write + immediate exit)
|
|
4
|
+
// Fix applied: process.stdout.write(payload, () => process.exit(0)) on warning path.
|
|
3
5
|
// framework Workflow Guard — PreToolUse hook
|
|
4
6
|
// Detects when Claude attempts file edits outside a framework workflow context
|
|
5
7
|
// (no active / command or Task subagent) and injects an advisory warning.
|
|
@@ -86,7 +88,12 @@ process.stdin.on('end', () => {
|
|
|
86
88
|
}
|
|
87
89
|
};
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
// SEC-13-05: aguardar flush do stdout antes do exit. Sem callback, em
|
|
92
|
+
// pipes lentos (CI/Windows/Git Bash) o JSON pode ser dropado quando o
|
|
93
|
+
// process termina antes do kernel drenar o buffer.
|
|
94
|
+
process.stdout.write(JSON.stringify(output), () => {
|
|
95
|
+
process.exit(0);
|
|
96
|
+
});
|
|
90
97
|
} catch (e) {
|
|
91
98
|
// Silent fail — never block tool execution
|
|
92
99
|
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luanpdd/kit-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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": {
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
"kit/",
|
|
17
17
|
"gates/",
|
|
18
18
|
"README.md",
|
|
19
|
-
"CHANGELOG.md",
|
|
20
19
|
"LICENSE"
|
|
21
20
|
],
|
|
22
21
|
"keywords": [
|
package/src/cli/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { listKit, searchKit, findItem } from '../core/kit.js';
|
|
20
20
|
import { listTargets } from '../core/registry.js';
|
|
21
|
-
import { syncTo, statusOf, removeFrom } from '../core/sync.js';
|
|
21
|
+
import { syncTo, statusOf, removeFrom, summarize } from '../core/sync.js';
|
|
22
22
|
import { watchKit, detectExistingTargets } from '../core/watch.js';
|
|
23
23
|
import { listGates, getGate, gatesForStage } from '../core/gates.js';
|
|
24
24
|
import { runGate } from '../core/gate-runner.js';
|
|
@@ -148,7 +148,10 @@ function fail(msg) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
function slim(x) {
|
|
151
|
-
|
|
151
|
+
// PERF-13-01: cap description at SUMMARY_MAX_CHARS via shared summarize()
|
|
152
|
+
// helper from src/core/sync.js — keeps cross-surface behavior identical
|
|
153
|
+
// (CLI listing == MCP listing). Full text remains in each item's source file.
|
|
154
|
+
return { kind: x.kind, name: x.name, description: summarize(x.description) };
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
// --- kit ---
|
|
@@ -426,7 +429,7 @@ ui.command('stop')
|
|
|
426
429
|
const lock = readLock(projectRoot);
|
|
427
430
|
if (!lock) return out({ ok: false, reason: 'no_sidecar' }, () => `${icons.warn} no sidecar running for this project\n`);
|
|
428
431
|
try {
|
|
429
|
-
await postShutdown(lock.port);
|
|
432
|
+
await postShutdown(lock.port, lock.token);
|
|
430
433
|
out({ ok: true, port: lock.port }, () => `${icons.check} sidecar at port ${lock.port} stopped\n`);
|
|
431
434
|
} catch (err) {
|
|
432
435
|
fail(`could not stop sidecar at port ${lock.port}: ${err.message}`);
|
|
@@ -637,15 +640,24 @@ async function runDoctorChecks(projectRoot) {
|
|
|
637
640
|
}
|
|
638
641
|
|
|
639
642
|
// Helpers for kit ui (live in cli/ — stdout/console allowed here)
|
|
640
|
-
|
|
643
|
+
// SEC-14-02: /shutdown now requires Authorization Bearer <token>. Caller must
|
|
644
|
+
// pass the per-process token read from the lockfile (lock.token from readLock).
|
|
645
|
+
async function postShutdown(port, token) {
|
|
641
646
|
return new Promise((resolve, reject) => {
|
|
647
|
+
const headers = {
|
|
648
|
+
host: `127.0.0.1:${port}`,
|
|
649
|
+
origin: `http://127.0.0.1:${port}`,
|
|
650
|
+
'content-length': 0,
|
|
651
|
+
connection: 'close',
|
|
652
|
+
};
|
|
653
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
642
654
|
const req = http.request({
|
|
643
655
|
method: 'POST',
|
|
644
656
|
host: '127.0.0.1',
|
|
645
657
|
port,
|
|
646
658
|
path: '/shutdown',
|
|
647
659
|
agent: false,
|
|
648
|
-
headers
|
|
660
|
+
headers,
|
|
649
661
|
}, (res) => {
|
|
650
662
|
res.resume();
|
|
651
663
|
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
|
+
}
|
package/src/core/gate-runner.js
CHANGED
|
@@ -132,9 +132,17 @@ function extractCodeBlocks(text) {
|
|
|
132
132
|
// --- exec ---
|
|
133
133
|
|
|
134
134
|
async function execScript(script, cwd) {
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/src/core/reflect.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|