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.
- package/dist/agent/anthropic-adapter.d.ts +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- package/package.json +1 -1
package/dist/mcp/config.d.ts
CHANGED
|
@@ -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). */
|
package/dist/mcp/config.js
CHANGED
|
@@ -25,7 +25,16 @@ export const McpServerSpecSchema = z.object({
|
|
|
25
25
|
disabled: z.boolean().optional(),
|
|
26
26
|
policy: McpServerPolicySchema.optional(),
|
|
27
27
|
});
|
|
28
|
-
|
|
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))
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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>;
|
package/dist/mcp/registry.js
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/mcp/schema.d.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
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;
|
package/dist/mcp/schema.js
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|