@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.
- package/kit/file-manifest.json +154 -48
- package/kit/hooks/sidecar-tool-publisher.js +36 -14
- package/package.json +1 -1
- package/src/cli/index.js +12 -3
- 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 +10 -1
- package/src/core/sync.js +13 -0
- package/src/mcp-server/index.js +35 -9
- 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
|
@@ -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 {
|
package/src/core/replays.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/mcp-server/index.js
CHANGED
|
@@ -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':
|
|
197
|
+
case 'status':
|
|
196
198
|
case 'install':
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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':
|
|
207
|
-
case 'apply':
|
|
208
|
-
|
|
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
|
|
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(
|
|
333
|
+
content: [{ type: 'text', text: JSON.stringify(sanitizeMcpError(e), null, 2) }],
|
|
308
334
|
isError: true,
|
|
309
335
|
};
|
|
310
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 {
|