klyro 1.0.0 → 1.0.2

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 (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  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 +124 -6
  8. package/dist/agent/orchestrator.js +425 -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 +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. package/package.json +1 -1
@@ -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))
@@ -1,21 +1,3 @@
1
- /**
2
- * P1 — MCP tool registration (r-11-17.md §3.1).
3
- *
4
- * Exposes MCP server tools through the {@link ToolRegistry} under
5
- * `mcp__<server>__<tool>` names. Security properties (non-negotiable):
6
- *
7
- * 1. Deny-by-default: `evaluateMcpPolicy` runs BEFORE any client I/O; a
8
- * denied tool returns POLICY_DENIED without touching the subprocess.
9
- * 2. Redact-before-transcript: every success value AND error message passes
10
- * through `redact()` before it can reach the model or trace.
11
- * 3. `requireApproval` servers add an ask-rule to the PolicyEngine so the
12
- * runtime loop prompts before executing.
13
- *
14
- * Permission class: no code in src consumes `Tool.permission` (it is
15
- * write-only metadata today), so we pick the most restrictive class,
16
- * 'admin' — the same class as `spawn_agent`, since MCP tools execute
17
- * arbitrary external side effects (read/write/network) outside our control.
18
- */
19
1
  import { type McpClientLike } from './client.js';
20
2
  import { type McpServerSpec } from './config.js';
21
3
  import type { PolicyEngine } from '../policy/engine.js';
@@ -37,7 +19,16 @@ export interface RegisterMcpOpts {
37
19
  policy?: PolicyEngine;
38
20
  clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
39
21
  }
40
- /** `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64. */
22
+ /** Success values are redacted FIRST, then truncated to this many chars. */
23
+ export declare const MCP_SUCCESS_MAX_CHARS = 12000;
24
+ /**
25
+ * `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64.
26
+ *
27
+ * No `'server'`/`'tool'` fallbacks: an empty raw part (or one that
28
+ * sanitizes to empty) yields an empty segment, and registration skips that
29
+ * tool with an `empty-name` error instead of masking a misconfiguration
30
+ * behind a plausible-looking name.
31
+ */
41
32
  export declare function sanitizeMcpName(server: string, tool: string): string;
42
33
  export declare function registerMcpServers(cfg: {
43
34
  servers: Record<string, McpServerSpec>;
@@ -47,4 +38,17 @@ export declare function loadAndRegisterMcp(opts: {
47
38
  registry: ToolRegistry;
48
39
  policy?: PolicyEngine;
49
40
  clientFactory?: (name: string, spec: McpServerSpec) => McpClientLike;
41
+ /**
42
+ * Consent gate for project-sourced (`.mcp.json`) servers, which can
43
+ * auto-spawn processes. Global-source servers connect as before; a project
44
+ * server connects ONLY when this callback returns true. When the callback
45
+ * is ABSENT, project servers are never auto-connected — each surfaces as
46
+ * an error (`project server requires approval (skipped)`) so the skip is
47
+ * visible. Callers may compose this with `McpTrust` (see `./trust.js`) to
48
+ * remember approvals per spec hash.
49
+ */
50
+ approveProjectServer?: (info: {
51
+ name: string;
52
+ source: 'global' | 'project';
53
+ }) => Promise<boolean>;
50
54
  }): Promise<McpRegisterResult>;
@@ -15,7 +15,18 @@
15
15
  * write-only metadata today), so we pick the most restrictive class,
16
16
  * 'admin' — the same class as `spawn_agent`, since MCP tools execute
17
17
  * arbitrary external side effects (read/write/network) outside our control.
18
+ *
19
+ * Debug capture: when `KLYRO_MCP_DEBUG=1` is set, every MCP tool success
20
+ * AND error ALSO writes the UNREDACTED raw JSON payload (pre-redaction,
21
+ * may contain secrets — handle accordingly) to
22
+ * `<configDir>/tool-output/mcp-<server>-<ts>.json` (mode 0600) and notes
23
+ * the path on stderr. `<configDir>` is `$KLYRO_CONFIG_DIR` when set,
24
+ * otherwise `~/.klyro`. Default off: with the flag unset (or any value
25
+ * other than `1`) no file is written and behaviour is unchanged.
18
26
  */
27
+ import * as fs from 'node:fs';
28
+ import * as os from 'node:os';
29
+ import * as path from 'node:path';
19
30
  import { McpClient, McpError } from './client.js';
20
31
  import { loadMcpServers } from './config.js';
21
32
  import { evaluateMcpPolicy } from './policy.js';
@@ -26,20 +37,61 @@ import { defineTool } from '../tools/types.js';
26
37
  const MAX_NAME_LEN = 64;
27
38
  /** Server part is capped here; the tool part takes whatever remains. */
28
39
  const MAX_SERVER_PART = 20;
40
+ /** Success values are redacted FIRST, then truncated to this many chars. */
41
+ export const MCP_SUCCESS_MAX_CHARS = 12_000;
29
42
  function sanitizePart(s) {
30
43
  return s.replace(/[^A-Za-z0-9_]/g, '_');
31
44
  }
32
- /** `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64. */
45
+ /**
46
+ * `mcp__<server>__<tool>`, sanitized, server part ≤20 chars, total ≤64.
47
+ *
48
+ * No `'server'`/`'tool'` fallbacks: an empty raw part (or one that
49
+ * sanitizes to empty) yields an empty segment, and registration skips that
50
+ * tool with an `empty-name` error instead of masking a misconfiguration
51
+ * behind a plausible-looking name.
52
+ */
33
53
  export function sanitizeMcpName(server, tool) {
34
- const srv = sanitizePart(server).slice(0, MAX_SERVER_PART) || 'server';
54
+ const srv = sanitizePart(server).slice(0, MAX_SERVER_PART);
35
55
  const maxTool = Math.max(1, MAX_NAME_LEN - 'mcp__'.length - srv.length - '__'.length);
36
- const tl = sanitizePart(tool).slice(0, maxTool) || 'tool';
56
+ const tl = sanitizePart(tool).slice(0, maxTool);
37
57
  return `mcp__${srv}__${tl}`;
38
58
  }
39
59
  function errMessage(err) {
40
60
  return err instanceof Error ? err.message : String(err);
41
61
  }
42
- async function executeMcpTool(spec, toolDef, client, input, ctx) {
62
+ /** Debug-capture filename disambiguator when Date.now() collides. */
63
+ let mcpDebugCounter = 0;
64
+ /**
65
+ * `KLYRO_MCP_DEBUG=1` capture: write the UNREDACTED raw payload to
66
+ * `<configDir>/tool-output/mcp-<server>-<ts>.json` (mode 0600) + a stderr
67
+ * note. Best-effort and synchronous — never throws into the tool path.
68
+ */
69
+ function captureMcpDebug(server, tool, payload) {
70
+ if (process.env.KLYRO_MCP_DEBUG !== '1')
71
+ return;
72
+ try {
73
+ const base = process.env.KLYRO_CONFIG_DIR ?? path.join(os.homedir() || process.cwd(), '.klyro');
74
+ const dir = path.join(base, 'tool-output');
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ const safe = server.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 32) || 'server';
77
+ let file = path.join(dir, `mcp-${safe}-${Date.now()}.json`);
78
+ if (fs.existsSync(file)) {
79
+ mcpDebugCounter += 1;
80
+ file = path.join(dir, `mcp-${safe}-${Date.now()}-${mcpDebugCounter}.json`);
81
+ }
82
+ fs.writeFileSync(file, JSON.stringify({ server, tool, payload }, null, 2), { mode: 0o600 });
83
+ try {
84
+ process.stderr.write(`klyro: mcp debug captured ${server}/${tool} -> ${file}\n`);
85
+ }
86
+ catch {
87
+ /* ignore */
88
+ }
89
+ }
90
+ catch {
91
+ /* best-effort only */
92
+ }
93
+ }
94
+ async function executeMcpTool(server, spec, toolDef, client, input, ctx) {
43
95
  try {
44
96
  // (1) Deny-by-default — runs BEFORE any client I/O.
45
97
  const decision = evaluateMcpPolicy(spec.policy, toolDef.name);
@@ -58,16 +110,29 @@ async function executeMcpTool(spec, toolDef, client, input, ctx) {
58
110
  // (3) Typed server failures keep their code; everything else is TOOL_ERROR.
59
111
  // Redact BEFORE the message can reach the model/trace.
60
112
  if (err instanceof McpError) {
113
+ captureMcpDebug(server, toolDef.name, { code: err.code, message: err.message, details: err.details });
61
114
  return { ok: false, error: { code: err.code, message: redact(err.message) } };
62
115
  }
116
+ captureMcpDebug(server, toolDef.name, { message: errMessage(err) });
63
117
  return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
64
118
  }
65
119
  // (4) Server-reported error → TOOL_ERROR, redacted, bounded.
66
120
  if (res.isError) {
121
+ captureMcpDebug(server, toolDef.name, res.raw);
67
122
  return { ok: false, error: { code: 'TOOL_ERROR', message: redact(res.text).slice(0, 2000) } };
68
123
  }
69
- // (5) Success — redact BEFORE the value reaches the model/trace.
70
- return { ok: true, value: redact(res.text) };
124
+ // (5) Success — redact BEFORE the value reaches the model/trace, then
125
+ // truncate to a bounded size with a marker.
126
+ captureMcpDebug(server, toolDef.name, res.raw);
127
+ const redacted = redact(res.text);
128
+ if (redacted.length > MCP_SUCCESS_MAX_CHARS) {
129
+ return {
130
+ ok: true,
131
+ value: redacted.slice(0, MCP_SUCCESS_MAX_CHARS) +
132
+ `\n... [truncated ${redacted.length - MCP_SUCCESS_MAX_CHARS} chars]`,
133
+ };
134
+ }
135
+ return { ok: true, value: redacted };
71
136
  }
72
137
  catch (err) {
73
138
  // Tool contract: never throw — always return a ToolResult.
@@ -81,6 +146,9 @@ export async function registerMcpServers(cfg, opts) {
81
146
  const errors = [];
82
147
  const skipped = [];
83
148
  const clients = [];
149
+ // Sanitized name → first raw (server, tool) that claimed it, used to tell
150
+ // sanitization-collisions apart from exact-duplicates (see below).
151
+ const claimed = new Map();
84
152
  for (const [server, spec] of Object.entries(cfg.servers)) {
85
153
  try {
86
154
  // enabledServers-style: skip disabled entries silently.
@@ -116,11 +184,35 @@ export async function registerMcpServers(cfg, opts) {
116
184
  }
117
185
  clients.push(client);
118
186
  for (const toolDef of tools) {
187
+ // Empty raw names (or names that sanitize to empty) are a
188
+ // misconfiguration — skip as an error, never with a fallback name.
189
+ if (!server || !toolDef.name || sanitizePart(server) === '' || sanitizePart(toolDef.name) === '') {
190
+ errors.push({ server, message: `empty-name: server "${server}" tool "${toolDef.name}" sanitizes to an empty name part (skipped)` });
191
+ continue;
192
+ }
119
193
  const name = sanitizeMcpName(server, toolDef.name);
194
+ const prior = claimed.get(name);
195
+ if (prior) {
196
+ if (prior.server === server && prior.tool === toolDef.name) {
197
+ skipped.push({ name, reason: 'name-collision' });
198
+ continue;
199
+ }
200
+ // Sanitization-collision: two DIFFERENT raw names map to the same
201
+ // sanitized name. This is an error (not a silent skip) and names
202
+ // both raw identities so the conflict is actionable.
203
+ errors.push({
204
+ server,
205
+ message: `name collision: "${prior.server}/${prior.tool}" and "${server}/${toolDef.name}" both sanitize to "${name}" (skipped)`,
206
+ });
207
+ continue;
208
+ }
120
209
  if (registry.get(name)) {
210
+ // Exact-duplicate: the literal name already exists (e.g. a
211
+ // builtin) — skip quietly, first registration wins.
121
212
  skipped.push({ name, reason: 'name-collision' });
122
213
  continue;
123
214
  }
215
+ claimed.set(name, { server, tool: toolDef.name });
124
216
  // Capture per-tool bindings for the closure.
125
217
  const boundSpec = spec;
126
218
  const boundDef = toolDef;
@@ -132,7 +224,7 @@ export async function registerMcpServers(cfg, opts) {
132
224
  // 'admin': most restrictive class — MCP tools run arbitrary
133
225
  // external side effects; nothing in src reads this field yet.
134
226
  permission: 'admin',
135
- execute: (input, ctx) => executeMcpTool(boundSpec, boundDef, boundClient, input, ctx),
227
+ execute: (input, ctx) => executeMcpTool(server, boundSpec, boundDef, boundClient, input, ctx),
136
228
  });
137
229
  registry.register(tool);
138
230
  registered.push(name);
@@ -158,7 +250,34 @@ export async function registerMcpServers(cfg, opts) {
158
250
  export async function loadAndRegisterMcp(opts) {
159
251
  try {
160
252
  const cfg = loadMcpServers(opts.cwd);
161
- return await registerMcpServers(cfg, opts);
253
+ const filtered = {};
254
+ const gateErrors = [];
255
+ for (const [name, spec] of Object.entries(cfg.servers)) {
256
+ if (cfg.sources[name] !== 'project') {
257
+ filtered[name] = spec;
258
+ continue;
259
+ }
260
+ if (!opts.approveProjectServer) {
261
+ gateErrors.push({ server: name, message: 'project server requires approval (skipped)' });
262
+ continue;
263
+ }
264
+ let ok = false;
265
+ try {
266
+ ok = await opts.approveProjectServer({ name, source: 'project' });
267
+ }
268
+ catch (err) {
269
+ gateErrors.push({ server: name, message: `project server approval error (skipped): ${errMessage(err)}` });
270
+ continue;
271
+ }
272
+ if (!ok) {
273
+ gateErrors.push({ server: name, message: 'project server not approved (skipped)' });
274
+ continue;
275
+ }
276
+ filtered[name] = spec;
277
+ }
278
+ const res = await registerMcpServers({ servers: filtered }, opts);
279
+ res.errors.unshift(...gateErrors);
280
+ return res;
162
281
  }
163
282
  catch (err) {
164
283
  // 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
+ }>;
@@ -2,14 +2,114 @@
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
  */
11
+ import * as crypto from 'node:crypto';
12
+ import * as fs from 'node:fs/promises';
13
+ import * as path from 'node:path';
6
14
  import { SessionStore } from './store.js';
15
+ export const AUDIT_GENESIS = 'GENESIS';
16
+ /** Canonical JSON: object keys sorted recursively, so hashes are stable. */
17
+ export function canonicalJson(value) {
18
+ if (value === null || typeof value !== 'object')
19
+ return JSON.stringify(value) ?? 'null';
20
+ if (Array.isArray(value))
21
+ return `[${value.map(canonicalJson).join(',')}]`;
22
+ const rec = value;
23
+ const keys = Object.keys(rec).sort();
24
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(rec[k])}`).join(',')}}`;
25
+ }
26
+ export function sha256Hex(s) {
27
+ return crypto.createHash('sha256').update(s, 'utf-8').digest('hex');
28
+ }
29
+ /** Hash of a chained record excluding its own `hash` field. */
30
+ export function hashAuditRecord(record) {
31
+ const { hash: _omit, ...body } = record;
32
+ void _omit;
33
+ return sha256Hex(canonicalJson(body));
34
+ }
35
+ async function readLastLine(filePath) {
36
+ try {
37
+ const raw = await fs.readFile(filePath, 'utf-8');
38
+ const lines = raw.split('\n').filter((l) => l.trim());
39
+ return lines.length > 0 ? lines[lines.length - 1] : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** prevHash for the next record: hash of the previous line, or GENESIS. */
46
+ async function prevHashFor(filePath) {
47
+ const last = await readLastLine(filePath);
48
+ if (!last)
49
+ return AUDIT_GENESIS;
50
+ try {
51
+ const parsed = JSON.parse(last);
52
+ if (typeof parsed.hash === 'string' && parsed.hash)
53
+ return parsed.hash;
54
+ return sha256Hex(canonicalJson(parsed));
55
+ }
56
+ catch {
57
+ return sha256Hex(last);
58
+ }
59
+ }
7
60
  export class AuditLog {
8
61
  filePath;
62
+ /** Serializes chained appends so concurrent writes can't fork the chain. */
63
+ chain = Promise.resolve();
9
64
  constructor(filePath) {
10
65
  this.filePath = filePath;
11
66
  }
12
67
  async write(event) {
13
- await SessionStore.appendJsonl(this.filePath, event);
68
+ const task = this.chain.then(() => this.appendChained(event));
69
+ // Keep the chain alive across failures; the caller still sees its error.
70
+ this.chain = task.catch(() => undefined);
71
+ return task;
72
+ }
73
+ async appendChained(event) {
74
+ const prevHash = await prevHashFor(this.filePath);
75
+ const body = { ...event, prevHash };
76
+ const record = { ...body, prevHash, hash: sha256Hex(canonicalJson(body)) };
77
+ await SessionStore.appendJsonl(this.filePath, record);
78
+ }
79
+ }
80
+ /**
81
+ * Recompute the hash chain of a session JSONL file.
82
+ * Sessions live at `<sessionsDir>/<sessionId>.jsonl` (see SessionStore).
83
+ */
84
+ export async function verifyAuditChain(sessionsDir, sessionId) {
85
+ const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
86
+ let raw;
87
+ try {
88
+ raw = await fs.readFile(filePath, 'utf-8');
89
+ }
90
+ catch {
91
+ return { ok: false, events: 0, error: `audit log not found: ${filePath}` };
92
+ }
93
+ const lines = raw.split('\n').filter((l) => l.trim());
94
+ let expectedPrev = AUDIT_GENESIS;
95
+ let events = 0;
96
+ for (let i = 0; i < lines.length; i++) {
97
+ const line = lines[i];
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(line);
101
+ }
102
+ catch {
103
+ return { ok: false, events, error: `line ${i + 1}: unparseable JSON` };
104
+ }
105
+ if (parsed.prevHash !== expectedPrev) {
106
+ return { ok: false, events, error: `line ${i + 1}: prevHash mismatch (chain fork or truncation)` };
107
+ }
108
+ if (typeof parsed.hash !== 'string' || parsed.hash !== hashAuditRecord(parsed)) {
109
+ return { ok: false, events, error: `line ${i + 1}: hash mismatch (tampered record)` };
110
+ }
111
+ expectedPrev = parsed.hash;
112
+ events++;
14
113
  }
114
+ return { ok: true, events };
15
115
  }