klyro 1.0.0 → 1.0.1

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 (96) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +68 -16
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +46 -5
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +106 -5
  8. package/dist/agent/orchestrator.js +378 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +2 -2
  13. package/dist/agent/runtime.d.ts +50 -8
  14. package/dist/agent/runtime.js +196 -33
  15. package/dist/agent/worktree-manager.d.ts +74 -0
  16. package/dist/agent/worktree-manager.js +189 -0
  17. package/dist/checkpoints/store.js +30 -5
  18. package/dist/cli/auth.js +16 -1
  19. package/dist/cli/config.d.ts +9 -3
  20. package/dist/cli/config.js +64 -3
  21. package/dist/cli/eval.d.ts +6 -1
  22. package/dist/cli/eval.js +9 -0
  23. package/dist/cli/repl.js +155 -28
  24. package/dist/cli/run.d.ts +7 -11
  25. package/dist/cli/run.js +68 -17
  26. package/dist/cli/update.d.ts +5 -0
  27. package/dist/cli/update.js +62 -10
  28. package/dist/context/import-graph.d.ts +2 -0
  29. package/dist/context/import-graph.js +31 -3
  30. package/dist/context/klyro-md.js +4 -1
  31. package/dist/context/memory.d.ts +8 -0
  32. package/dist/context/memory.js +50 -2
  33. package/dist/context/project-map.d.ts +6 -0
  34. package/dist/context/project-map.js +50 -2
  35. package/dist/context/repo-map.d.ts +2 -0
  36. package/dist/context/repo-map.js +31 -1
  37. package/dist/events/catalog.d.ts +28 -0
  38. package/dist/index.js +89 -4
  39. package/dist/mcp/client.d.ts +6 -4
  40. package/dist/mcp/client.js +83 -14
  41. package/dist/mcp/config.d.ts +10 -0
  42. package/dist/mcp/config.js +18 -1
  43. package/dist/mcp/registry.d.ts +23 -1
  44. package/dist/mcp/registry.js +78 -6
  45. package/dist/mcp/schema.d.ts +11 -4
  46. package/dist/mcp/schema.js +27 -16
  47. package/dist/mcp/trust.d.ts +20 -0
  48. package/dist/mcp/trust.js +74 -0
  49. package/dist/persistence/audit.d.ts +28 -0
  50. package/dist/persistence/audit.js +101 -1
  51. package/dist/persistence/store.d.ts +26 -2
  52. package/dist/persistence/store.js +140 -13
  53. package/dist/policy/approval.d.ts +14 -0
  54. package/dist/policy/approval.js +44 -2
  55. package/dist/policy/engine.d.ts +1 -0
  56. package/dist/policy/engine.js +88 -8
  57. package/dist/policy/secret-redactor.js +4 -0
  58. package/dist/providers/model-info.d.ts +17 -0
  59. package/dist/providers/model-info.js +35 -2
  60. package/dist/repl.d.ts +6 -0
  61. package/dist/repl.js +12 -7
  62. package/dist/tools/agent/spawn-agent.js +5 -5
  63. package/dist/tools/agent/task-apply.d.ts +4 -0
  64. package/dist/tools/agent/task-apply.js +44 -0
  65. package/dist/tools/agent/task-stop.d.ts +6 -0
  66. package/dist/tools/agent/task-stop.js +39 -0
  67. package/dist/tools/agent/task-wait.d.ts +17 -0
  68. package/dist/tools/agent/task-wait.js +79 -0
  69. package/dist/tools/fs/apply-patch.js +71 -0
  70. package/dist/tools/fs/edit-file.js +65 -0
  71. package/dist/tools/fs/multi-edit.d.ts +4 -0
  72. package/dist/tools/fs/multi-edit.js +66 -0
  73. package/dist/tools/fs/write-file.js +67 -0
  74. package/dist/tools/registry.js +6 -0
  75. package/dist/tools/shell/background.js +6 -3
  76. package/dist/tools/shell/sandbox.d.ts +51 -0
  77. package/dist/tools/shell/sandbox.js +143 -0
  78. package/dist/tools/shell/shell-exec.d.ts +1 -0
  79. package/dist/tools/shell/shell-exec.js +83 -11
  80. package/dist/tools/shell/worker-entry.d.ts +12 -0
  81. package/dist/tools/shell/worker-entry.js +43 -0
  82. package/dist/tools/types.d.ts +6 -0
  83. package/dist/tools/verify/run-verify.js +3 -1
  84. package/dist/trace/writer.d.ts +13 -0
  85. package/dist/trace/writer.js +55 -4
  86. package/dist/tui/app.js +1 -1
  87. package/dist/tui/approval.js +20 -21
  88. package/dist/util.d.ts +1 -0
  89. package/dist/util.js +1 -0
  90. package/dist/verification/baseline.js +17 -3
  91. package/dist/verification/classify.js +23 -12
  92. package/dist/verification/engine.js +3 -1
  93. package/dist/verification/registry.d.ts +2 -0
  94. package/dist/verification/registry.js +33 -0
  95. package/dist/verification/scoped.js +28 -6
  96. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
  * failures and server errors surface as typed `McpError`s — never throws
8
8
  * raw across the boundary.
9
9
  */
10
- import { spawn } from 'node:child_process';
10
+ import { execFileSync, spawn } from 'node:child_process';
11
11
  export class McpError extends Error {
12
12
  code;
13
13
  details;
@@ -21,6 +21,10 @@ export class McpError extends Error {
21
21
  const PROTOCOL_VERSION = '2024-11-05';
22
22
  const DEFAULT_TIMEOUT_MS = 30_000;
23
23
  const MAX_STDERR_BYTES = 8192;
24
+ /** Upper bound on spawn + initialize handshake; partial children are killed. */
25
+ const CONNECT_TIMEOUT_MS = 15_000;
26
+ /** Grace period between SIGTERM and SIGKILL in close(). */
27
+ const CLOSE_SIGKILL_AFTER_MS = 2000;
24
28
  export class McpClient {
25
29
  name;
26
30
  spec;
@@ -54,6 +58,12 @@ export class McpClient {
54
58
  return;
55
59
  }
56
60
  this.child = child;
61
+ // Swallow async stream errors (e.g. EPIPE on write-after-exit):
62
+ // request promises already surface failures via write callbacks and
63
+ // the exit handler, so these must never become uncaught exceptions.
64
+ child.stdin?.on('error', () => { });
65
+ child.stdout?.on('error', () => { });
66
+ child.stderr?.on('error', () => { });
57
67
  child.on('error', (err) => {
58
68
  this.failAll(new McpError(`mcp server "${this.name}" process error: ${err.message}`, 'SPAWN_FAILED'));
59
69
  if (!this.connected)
@@ -73,13 +83,29 @@ export class McpClient {
73
83
  resolve();
74
84
  });
75
85
  try {
76
- await this.request('initialize', {
77
- protocolVersion: PROTOCOL_VERSION,
78
- capabilities: {},
79
- clientInfo: { name: 'klyro', version: '1.0.0' },
80
- }, timeoutMs);
81
- this.notify('notifications/initialized', {});
82
- this.connected = true;
86
+ let connectTimer;
87
+ try {
88
+ await Promise.race([
89
+ (async () => {
90
+ await this.request('initialize', {
91
+ protocolVersion: PROTOCOL_VERSION,
92
+ capabilities: {},
93
+ clientInfo: { name: 'klyro', version: '1.0.1' },
94
+ }, timeoutMs);
95
+ this.notify('notifications/initialized', {});
96
+ this.connected = true;
97
+ })(),
98
+ new Promise((_resolve, reject) => {
99
+ connectTimer = setTimeout(() => {
100
+ reject(new McpError(`mcp server "${this.name}" connect timed out after ${CONNECT_TIMEOUT_MS}ms`, 'TIMEOUT'));
101
+ }, CONNECT_TIMEOUT_MS);
102
+ }),
103
+ ]);
104
+ }
105
+ finally {
106
+ if (connectTimer)
107
+ clearTimeout(connectTimer);
108
+ }
83
109
  }
84
110
  catch (err) {
85
111
  await this.close();
@@ -87,9 +113,9 @@ export class McpClient {
87
113
  }
88
114
  }
89
115
  connected = false;
90
- async listTools() {
116
+ async listTools(signal) {
91
117
  this.assertLive();
92
- const res = (await this.request('tools/list', {}, this.timeout()));
118
+ const res = (await this.request('tools/list', {}, this.timeout(), signal));
93
119
  return Array.isArray(res.tools) ? res.tools : [];
94
120
  }
95
121
  async callTool(name, args, signal) {
@@ -99,9 +125,9 @@ export class McpClient {
99
125
  const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
100
126
  return { text, isError: res.isError === true, raw: res };
101
127
  }
102
- async listResources() {
128
+ async listResources(signal) {
103
129
  this.assertLive();
104
- const res = (await this.request('resources/list', {}, this.timeout()));
130
+ const res = (await this.request('resources/list', {}, this.timeout(), signal));
105
131
  return Array.isArray(res.resources) ? res.resources : [];
106
132
  }
107
133
  async readResource(uri, signal) {
@@ -111,16 +137,49 @@ export class McpClient {
111
137
  return contents.map((c) => (typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\n');
112
138
  }
113
139
  async close() {
140
+ // Idempotent vs the exit handler: closed is set FIRST (the on('exit')
141
+ // callback checks it before failAll), and a second close() with no child
142
+ // left is a no-op.
143
+ if (this.closed && this.child === null)
144
+ return;
114
145
  this.closed = true;
115
146
  this.failAll(new McpError(`mcp server "${this.name}" closed`, 'CLOSED'));
116
147
  const child = this.child;
117
148
  this.child = null;
118
- if (child && !child.killed) {
149
+ if (!child)
150
+ return;
151
+ // Already reaped — no escalation timer needed.
152
+ if (child.exitCode !== null || child.signalCode !== null)
153
+ return;
154
+ if (process.platform === 'win32') {
155
+ try {
156
+ if (child.pid !== undefined) {
157
+ execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
158
+ }
159
+ }
160
+ catch { /* ignore — fall through to kill() */ }
119
161
  try {
120
162
  child.kill();
121
163
  }
122
164
  catch { /* ignore */ }
165
+ return;
166
+ }
167
+ try {
168
+ child.kill('SIGTERM');
123
169
  }
170
+ catch { /* already gone */ }
171
+ const timer = setTimeout(() => {
172
+ try {
173
+ child.kill('SIGKILL');
174
+ }
175
+ catch { /* already gone */ }
176
+ }, CLOSE_SIGKILL_AFTER_MS);
177
+ timer.unref?.();
178
+ child.once('exit', () => clearTimeout(timer));
179
+ }
180
+ /** Test hook: number of in-flight JSON-RPC requests. */
181
+ pendingCount() {
182
+ return this.pending.size;
124
183
  }
125
184
  /* ---------------- internals ---------------- */
126
185
  timeout() {
@@ -154,7 +213,8 @@ export class McpClient {
154
213
  continue;
155
214
  this.pending.delete(msg.id);
156
215
  clearTimeout(pend.timer);
157
- pend.signal?.removeEventListener('abort', pend.onAbort);
216
+ if (pend.signal && pend.onAbort)
217
+ pend.signal.removeEventListener('abort', pend.onAbort);
158
218
  if (msg.error) {
159
219
  pend.reject(new McpError(`mcp server "${this.name}" error: ${msg.error.message ?? 'unknown'}`, 'SERVER_ERROR', msg.error));
160
220
  }
@@ -181,6 +241,9 @@ export class McpClient {
181
241
  }
182
242
  const id = this.nextId++;
183
243
  const timer = setTimeout(() => {
244
+ const p = this.pending.get(id);
245
+ if (p?.signal && p.onAbort)
246
+ p.signal.removeEventListener('abort', p.onAbort);
184
247
  this.pending.delete(id);
185
248
  reject(new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT'));
186
249
  }, timeoutMs);
@@ -202,6 +265,8 @@ export class McpClient {
202
265
  if (err) {
203
266
  this.pending.delete(id);
204
267
  clearTimeout(timer);
268
+ if (signal && pend.onAbort)
269
+ signal.removeEventListener('abort', pend.onAbort);
205
270
  reject(new McpError(`mcp call "${method}" write failed: ${err.message}`, 'IO_ERROR'));
206
271
  }
207
272
  });
@@ -209,6 +274,8 @@ export class McpClient {
209
274
  catch (err) {
210
275
  this.pending.delete(id);
211
276
  clearTimeout(timer);
277
+ if (signal && pend.onAbort)
278
+ signal.removeEventListener('abort', pend.onAbort);
212
279
  reject(new McpError(`mcp call "${method}" write failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR'));
213
280
  }
214
281
  });
@@ -219,6 +286,8 @@ export class McpClient {
219
286
  for (const [id, pend] of [...this.pending]) {
220
287
  this.pending.delete(id);
221
288
  clearTimeout(pend.timer);
289
+ if (pend.signal && pend.onAbort)
290
+ pend.signal.removeEventListener('abort', pend.onAbort);
222
291
  pend.reject(err);
223
292
  }
224
293
  }
@@ -24,6 +24,16 @@ export interface McpServersConfig {
24
24
  /** Where each server entry came from (for doctor/diagnostics). */
25
25
  sources: Record<string, string>;
26
26
  }
27
+ /** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
28
+ export declare const MAX_MCP_TIMEOUT_MS = 600000;
29
+ /**
30
+ * Expand `${env:VAR}` references from `process.env`.
31
+ *
32
+ * A reference to an UNSET or empty variable expands to `''` (silent empty
33
+ * expansion): the entry is kept with the empty value rather than rejected,
34
+ * so callers always see the effective spec. Pure — exported for unit tests.
35
+ */
36
+ export declare function expandEnv(value: string): string;
27
37
  /** Load + merge global and project MCP server specs. Invalid entries are skipped. */
28
38
  export declare function loadMcpServers(cwd: string): McpServersConfig;
29
39
  /** Servers eligible for connection (configured and not disabled). */
@@ -25,7 +25,16 @@ export const McpServerSpecSchema = z.object({
25
25
  disabled: z.boolean().optional(),
26
26
  policy: McpServerPolicySchema.optional(),
27
27
  });
28
- function expandEnv(value) {
28
+ /** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
29
+ export const MAX_MCP_TIMEOUT_MS = 600_000;
30
+ /**
31
+ * Expand `${env:VAR}` references from `process.env`.
32
+ *
33
+ * A reference to an UNSET or empty variable expands to `''` (silent empty
34
+ * expansion): the entry is kept with the empty value rather than rejected,
35
+ * so callers always see the effective spec. Pure — exported for unit tests.
36
+ */
37
+ export function expandEnv(value) {
29
38
  return value.replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name) => process.env[name] ?? '');
30
39
  }
31
40
  function normalizeRaw(raw) {
@@ -55,10 +64,18 @@ export function loadMcpServers(cwd) {
55
64
  for (const [filePath, label] of [[globalPath, 'global'], [projectPath, 'project']]) {
56
65
  const raw = normalizeRaw(readJsonFile(filePath));
57
66
  for (const [name, specRaw] of Object.entries(raw)) {
67
+ // Empty server names can never produce a valid tool name — skip
68
+ // silently (shape stays `{servers, sources}`; registry reports
69
+ // empty TOOL names as errors at registration time).
70
+ if (name === '')
71
+ continue;
58
72
  const parsed = McpServerSpecSchema.safeParse(specRaw);
59
73
  if (!parsed.success)
60
74
  continue;
61
75
  const spec = parsed.data;
76
+ if (spec.policy?.timeoutMs !== undefined && spec.policy.timeoutMs > MAX_MCP_TIMEOUT_MS) {
77
+ spec.policy.timeoutMs = MAX_MCP_TIMEOUT_MS;
78
+ }
62
79
  if (spec.env) {
63
80
  const env = {};
64
81
  for (const [k, v] of Object.entries(spec.env))
@@ -37,7 +37,16 @@ export interface RegisterMcpOpts {
37
37
  policy?: PolicyEngine;
38
38
  clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
39
39
  }
40
- /** `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64. */
40
+ /** Success values are redacted FIRST, then truncated to this many chars. */
41
+ export declare const MCP_SUCCESS_MAX_CHARS = 12000;
42
+ /**
43
+ * `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64.
44
+ *
45
+ * No `'server'`/`'tool'` fallbacks: an empty raw part (or one that
46
+ * sanitizes to empty) yields an empty segment, and registration skips that
47
+ * tool with an `empty-name` error instead of masking a misconfiguration
48
+ * behind a plausible-looking name.
49
+ */
41
50
  export declare function sanitizeMcpName(server: string, tool: string): string;
42
51
  export declare function registerMcpServers(cfg: {
43
52
  servers: Record<string, McpServerSpec>;
@@ -47,4 +56,17 @@ export declare function loadAndRegisterMcp(opts: {
47
56
  registry: ToolRegistry;
48
57
  policy?: PolicyEngine;
49
58
  clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
59
+ /**
60
+ * Consent gate for project-sourced (`.mcp.json`) servers, which can
61
+ * auto-spawn processes. Global-source servers connect as before; a project
62
+ * server connects ONLY when this callback returns true. When the callback
63
+ * is ABSENT, project servers are never auto-connected — each surfaces as
64
+ * an error (`project server requires approval (skipped)`) so the skip is
65
+ * visible. Callers may compose this with `McpTrust` (see `./trust.js`) to
66
+ * remember approvals per spec hash.
67
+ */
68
+ approveProjectServer?: (info: {
69
+ name: string;
70
+ source: 'global' | 'project';
71
+ }) => Promise<boolean>;
50
72
  }): Promise<McpRegisterResult>;
@@ -26,14 +26,23 @@ import { defineTool } from '../tools/types.js';
26
26
  const MAX_NAME_LEN = 64;
27
27
  /** Server part is capped here; the tool part takes whatever remains. */
28
28
  const MAX_SERVER_PART = 20;
29
+ /** Success values are redacted FIRST, then truncated to this many chars. */
30
+ export const MCP_SUCCESS_MAX_CHARS = 12_000;
29
31
  function sanitizePart(s) {
30
32
  return s.replace(/[^A-Za-z0-9_]/g, '_');
31
33
  }
32
- /** `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64. */
34
+ /**
35
+ * `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64.
36
+ *
37
+ * No `'server'`/`'tool'` fallbacks: an empty raw part (or one that
38
+ * sanitizes to empty) yields an empty segment, and registration skips that
39
+ * tool with an `empty-name` error instead of masking a misconfiguration
40
+ * behind a plausible-looking name.
41
+ */
33
42
  export function sanitizeMcpName(server, tool) {
34
- const srv = sanitizePart(server).slice(0, MAX_SERVER_PART) || 'server';
43
+ const srv = sanitizePart(server).slice(0, MAX_SERVER_PART);
35
44
  const maxTool = Math.max(1, MAX_NAME_LEN - 'mcp__'.length - srv.length - '__'.length);
36
- const tl = sanitizePart(tool).slice(0, maxTool) || 'tool';
45
+ const tl = sanitizePart(tool).slice(0, maxTool);
37
46
  return `mcp__${srv}__${tl}`;
38
47
  }
39
48
  function errMessage(err) {
@@ -66,8 +75,17 @@ async function executeMcpTool(spec, toolDef, client, input, ctx) {
66
75
  if (res.isError) {
67
76
  return { ok: false, error: { code: 'TOOL_ERROR', message: redact(res.text).slice(0, 2000) } };
68
77
  }
69
- // (5) Success — redact BEFORE the value reaches the model/trace.
70
- return { ok: true, value: redact(res.text) };
78
+ // (5) Success — redact BEFORE the value reaches the model/trace, then
79
+ // truncate to a bounded size with a marker.
80
+ const redacted = redact(res.text);
81
+ if (redacted.length > MCP_SUCCESS_MAX_CHARS) {
82
+ return {
83
+ ok: true,
84
+ value: redacted.slice(0, MCP_SUCCESS_MAX_CHARS) +
85
+ `\n... [truncated ${redacted.length - MCP_SUCCESS_MAX_CHARS} chars]`,
86
+ };
87
+ }
88
+ return { ok: true, value: redacted };
71
89
  }
72
90
  catch (err) {
73
91
  // Tool contract: never throw — always return a ToolResult.
@@ -81,6 +99,9 @@ export async function registerMcpServers(cfg, opts) {
81
99
  const errors = [];
82
100
  const skipped = [];
83
101
  const clients = [];
102
+ // Sanitized name → first raw (server, tool) that claimed it, used to tell
103
+ // sanitization-collisions apart from exact-duplicates (see below).
104
+ const claimed = new Map();
84
105
  for (const [server, spec] of Object.entries(cfg.servers)) {
85
106
  try {
86
107
  // enabledServers-style: skip disabled entries silently.
@@ -116,11 +137,35 @@ export async function registerMcpServers(cfg, opts) {
116
137
  }
117
138
  clients.push(client);
118
139
  for (const toolDef of tools) {
140
+ // Empty raw names (or names that sanitize to empty) are a
141
+ // misconfiguration — skip as an error, never with a fallback name.
142
+ if (!server || !toolDef.name || sanitizePart(server) === '' || sanitizePart(toolDef.name) === '') {
143
+ errors.push({ server, message: `empty-name: server "${server}" tool "${toolDef.name}" sanitizes to an empty name part (skipped)` });
144
+ continue;
145
+ }
119
146
  const name = sanitizeMcpName(server, toolDef.name);
147
+ const prior = claimed.get(name);
148
+ if (prior) {
149
+ if (prior.server === server && prior.tool === toolDef.name) {
150
+ skipped.push({ name, reason: 'name-collision' });
151
+ continue;
152
+ }
153
+ // Sanitization-collision: two DIFFERENT raw names map to the same
154
+ // sanitized name. This is an error (not a silent skip) and names
155
+ // both raw identities so the conflict is actionable.
156
+ errors.push({
157
+ server,
158
+ message: `name collision: "${prior.server}/${prior.tool}" and "${server}/${toolDef.name}" both sanitize to "${name}" (skipped)`,
159
+ });
160
+ continue;
161
+ }
120
162
  if (registry.get(name)) {
163
+ // Exact-duplicate: the literal name already exists (e.g. a
164
+ // builtin) — skip quietly, first registration wins.
121
165
  skipped.push({ name, reason: 'name-collision' });
122
166
  continue;
123
167
  }
168
+ claimed.set(name, { server, tool: toolDef.name });
124
169
  // Capture per-tool bindings for the closure.
125
170
  const boundSpec = spec;
126
171
  const boundDef = toolDef;
@@ -158,7 +203,34 @@ export async function registerMcpServers(cfg, opts) {
158
203
  export async function loadAndRegisterMcp(opts) {
159
204
  try {
160
205
  const cfg = loadMcpServers(opts.cwd);
161
- return await registerMcpServers(cfg, opts);
206
+ const filtered = {};
207
+ const gateErrors = [];
208
+ for (const [name, spec] of Object.entries(cfg.servers)) {
209
+ if (cfg.sources[name] !== 'project') {
210
+ filtered[name] = spec;
211
+ continue;
212
+ }
213
+ if (!opts.approveProjectServer) {
214
+ gateErrors.push({ server: name, message: 'project server requires approval (skipped)' });
215
+ continue;
216
+ }
217
+ let ok = false;
218
+ try {
219
+ ok = await opts.approveProjectServer({ name, source: 'project' });
220
+ }
221
+ catch (err) {
222
+ gateErrors.push({ server: name, message: `project server approval error (skipped): ${errMessage(err)}` });
223
+ continue;
224
+ }
225
+ if (!ok) {
226
+ gateErrors.push({ server: name, message: 'project server not approved (skipped)' });
227
+ continue;
228
+ }
229
+ filtered[name] = spec;
230
+ }
231
+ const res = await registerMcpServers({ servers: filtered }, opts);
232
+ res.errors.unshift(...gateErrors);
233
+ return res;
162
234
  }
163
235
  catch (err) {
164
236
  // Never throws — surface load failures as error entries.
@@ -2,10 +2,17 @@
2
2
  * P1 — JSON Schema → Zod converter for MCP tool input schemas.
3
3
  *
4
4
  * MCP servers describe inputs with JSON Schema; Klyro tools validate with
5
- * Zod. This converts the common subset (object/string/number/integer/
6
- * boolean/array/enum + required) and falls back to a permissive
7
- * `z.looseObject({}).catchall(z.unknown())`-style schema for anything else —
8
- * validation must never trust, but must also never crash on exotic schemas.
5
+ * Zod. Supported subset:
6
+ *
7
+ * - `type`: `object` (with `properties` + `required`), `string`,
8
+ * `number`, `integer`, `boolean`, `array` (with `items`)
9
+ * - string `enum` (handled before `type`)
10
+ * - a schema object WITHOUT a `type` but WITH a `properties` map is
11
+ * treated as `type: 'object'` (many servers omit the type)
12
+ *
13
+ * Anything else — an empty/absent schema, non-object input, an unknown
14
+ * `type` — falls back to `z.unknown()` (accept anything, validate nothing).
15
+ * Validation must never trust, but must also never crash on exotic schemas.
9
16
  */
10
17
  import { z } from 'zod';
11
18
  export declare function jsonSchemaToZod(schema: unknown): z.ZodTypeAny;
@@ -2,22 +2,43 @@
2
2
  * P1 — JSON Schema → Zod converter for MCP tool input schemas.
3
3
  *
4
4
  * MCP servers describe inputs with JSON Schema; Klyro tools validate with
5
- * Zod. This converts the common subset (object/string/number/integer/
6
- * boolean/array/enum + required) and falls back to a permissive
7
- * `z.looseObject({}).catchall(z.unknown())`-style schema for anything else —
8
- * validation must never trust, but must also never crash on exotic schemas.
5
+ * Zod. Supported subset:
6
+ *
7
+ * - `type`: `object` (with `properties` + `required`), `string`,
8
+ * `number`, `integer`, `boolean`, `array` (with `items`)
9
+ * - string `enum` (handled before `type`)
10
+ * - a schema object WITHOUT a `type` but WITH a `properties` map is
11
+ * treated as `type: 'object'` (many servers omit the type)
12
+ *
13
+ * Anything else — an empty/absent schema, non-object input, an unknown
14
+ * `type` — falls back to `z.unknown()` (accept anything, validate nothing).
15
+ * Validation must never trust, but must also never crash on exotic schemas.
9
16
  */
10
17
  import { z } from 'zod';
18
+ function objectFrom(s) {
19
+ const props = s['properties'] ?? {};
20
+ const required = new Set(Array.isArray(s['required']) ? s['required'].filter((v) => typeof v === 'string') : []);
21
+ const shape = {};
22
+ for (const [k, v] of Object.entries(props)) {
23
+ const inner = jsonSchemaToZod(v);
24
+ shape[k] = required.has(k) ? inner : inner.optional();
25
+ }
26
+ return z.looseObject(shape);
27
+ }
11
28
  export function jsonSchemaToZod(schema) {
12
29
  if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
13
- return z.looseObject({}).catchall(z.unknown());
30
+ return z.unknown();
14
31
  }
15
32
  const s = schema;
16
33
  const enumerated = Array.isArray(s['enum']) ? s['enum'] : undefined;
17
34
  if (enumerated && enumerated.length > 0 && enumerated.every((v) => typeof v === 'string')) {
18
35
  return z.enum(enumerated);
19
36
  }
20
- switch (s['type']) {
37
+ const t = s['type'];
38
+ if (t === 'object' || (t === undefined && s['properties'] !== undefined && typeof s['properties'] === 'object' && s['properties'] !== null && !Array.isArray(s['properties']))) {
39
+ return objectFrom(s);
40
+ }
41
+ switch (t) {
21
42
  case 'string':
22
43
  return z.string();
23
44
  case 'number':
@@ -30,16 +51,6 @@ export function jsonSchemaToZod(schema) {
30
51
  const items = jsonSchemaToZod(s['items']);
31
52
  return z.array(items);
32
53
  }
33
- case 'object': {
34
- const props = s['properties'] ?? {};
35
- const required = new Set(Array.isArray(s['required']) ? s['required'].filter((v) => typeof v === 'string') : []);
36
- const shape = {};
37
- for (const [k, v] of Object.entries(props)) {
38
- const inner = jsonSchemaToZod(v);
39
- shape[k] = required.has(k) ? inner : inner.optional();
40
- }
41
- return z.looseObject(shape);
42
- }
43
54
  default:
44
55
  return z.unknown();
45
56
  }
@@ -0,0 +1,20 @@
1
+ import type { McpServerSpec } from './config.js';
2
+ export interface McpTrustRecord {
3
+ sha256: string;
4
+ trustedAt: number;
5
+ }
6
+ export type McpTrustStore = Record<string, McpTrustRecord>;
7
+ /** sha256 of the canonical (key-sorted) JSON encoding of a server spec. */
8
+ export declare function hashSpec(spec: McpServerSpec): string;
9
+ export declare function defaultMcpTrustStorePath(): string;
10
+ export declare class McpTrust {
11
+ private readonly storePath;
12
+ private store;
13
+ constructor(storePath?: string);
14
+ private load;
15
+ private save;
16
+ /** True only when `name` was approved for exactly this spec hash. */
17
+ isTrusted(name: string, hash: string): boolean;
18
+ /** Record approval of `name` for exactly this spec hash. */
19
+ approve(name: string, hash: string): void;
20
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * P1 — MCP server-spec trust store (hash-based approval persistence).
3
+ *
4
+ * Companions the project-server consent gate in `registry.ts`: callers ask
5
+ * the user to approve a project-sourced server once via
6
+ * `approveProjectServer`, then persist `(name → sha256(spec))` here so later
7
+ * runs can auto-approve unchanged specs. Any spec change (new hash) requires
8
+ * fresh approval. Decisions persist in `~/.klyro/mcp-trust.json`.
9
+ *
10
+ * `registry.ts` does NOT use this class directly — callers compose it with
11
+ * the `approveProjectServer` callback. This module is intentionally
12
+ * side-effect free on import (file I/O happens in the constructor).
13
+ */
14
+ import * as crypto from 'node:crypto';
15
+ import * as fs from 'node:fs';
16
+ import * as os from 'node:os';
17
+ import * as path from 'node:path';
18
+ /** Deterministic JSON: object keys sorted recursively, arrays preserved. */
19
+ function stableStringify(value) {
20
+ if (value === null || typeof value !== 'object')
21
+ return JSON.stringify(value) ?? 'null';
22
+ if (Array.isArray(value))
23
+ return `[${value.map((v) => stableStringify(v)).join(',')}]`;
24
+ const entries = Object.entries(value)
25
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
26
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
27
+ return `{${entries.join(',')}}`;
28
+ }
29
+ /** sha256 of the canonical (key-sorted) JSON encoding of a server spec. */
30
+ export function hashSpec(spec) {
31
+ return crypto.createHash('sha256').update(stableStringify(spec), 'utf-8').digest('hex');
32
+ }
33
+ export function defaultMcpTrustStorePath() {
34
+ return path.join(os.homedir() || process.cwd(), '.klyro', 'mcp-trust.json');
35
+ }
36
+ export class McpTrust {
37
+ storePath;
38
+ store = {};
39
+ constructor(storePath = defaultMcpTrustStorePath()) {
40
+ this.storePath = storePath;
41
+ this.load();
42
+ }
43
+ load() {
44
+ try {
45
+ const raw = fs.readFileSync(this.storePath, 'utf-8');
46
+ const parsed = JSON.parse(raw);
47
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
48
+ this.store = parsed;
49
+ }
50
+ }
51
+ catch {
52
+ this.store = {};
53
+ }
54
+ }
55
+ save() {
56
+ try {
57
+ fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
58
+ fs.writeFileSync(this.storePath, JSON.stringify(this.store, null, 2), 'utf-8');
59
+ }
60
+ catch {
61
+ /* best-effort — trust stays in memory for the session */
62
+ }
63
+ }
64
+ /** True only when `name` was approved for exactly this spec hash. */
65
+ isTrusted(name, hash) {
66
+ const rec = this.store[name];
67
+ return !!rec && rec.sha256 === hash;
68
+ }
69
+ /** Record approval of `name` for exactly this spec hash. */
70
+ approve(name, hash) {
71
+ this.store[name] = { sha256: hash, trustedAt: Date.now() };
72
+ this.save();
73
+ }
74
+ }
@@ -2,6 +2,11 @@
2
2
  * Audit log — append-only JSONL of runtime events. Same shape as the
3
3
  * future durable-task scheduler will read, so the data layout is
4
4
  * forward-compatible.
5
+ *
6
+ * Durability: every record is hash-chained. Each line carries `prevHash`
7
+ * (hex sha256 of the previous line's canonical JSON, 'GENESIS' for the
8
+ * first line) and `hash` (sha256 of the record-with-prevHash canonical
9
+ * JSON). `verifyAuditChain` recomputes the chain for `klyro audit`.
5
10
  */
6
11
  export type AuditEvent = {
7
12
  kind: 'session_created';
@@ -68,8 +73,31 @@ export type AuditEvent = {
68
73
  attempt: number;
69
74
  ts: number;
70
75
  };
76
+ export declare const AUDIT_GENESIS = "GENESIS";
77
+ export interface ChainedAuditRecord {
78
+ prevHash: string;
79
+ hash: string;
80
+ [key: string]: unknown;
81
+ }
82
+ /** Canonical JSON: object keys sorted recursively, so hashes are stable. */
83
+ export declare function canonicalJson(value: unknown): string;
84
+ export declare function sha256Hex(s: string): string;
85
+ /** Hash of a chained record excluding its own `hash` field. */
86
+ export declare function hashAuditRecord(record: Record<string, unknown>): string;
71
87
  export declare class AuditLog {
72
88
  private readonly filePath;
89
+ /** Serializes chained appends so concurrent writes can't fork the chain. */
90
+ private chain;
73
91
  constructor(filePath: string);
74
92
  write(event: AuditEvent): Promise<void>;
93
+ private appendChained;
75
94
  }
95
+ /**
96
+ * Recompute the hash chain of a session JSONL file.
97
+ * Sessions live at `<sessionsDir>/<sessionId>.jsonl` (see SessionStore).
98
+ */
99
+ export declare function verifyAuditChain(sessionsDir: string, sessionId: string): Promise<{
100
+ ok: boolean;
101
+ events: number;
102
+ error?: string;
103
+ }>;