klyro 0.1.63 → 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.
- package/dist/agent/anthropic-adapter.d.ts +36 -0
- package/dist/agent/anthropic-adapter.js +73 -16
- package/dist/agent/capabilities.d.ts +145 -0
- package/dist/agent/capabilities.js +191 -0
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +232 -0
- package/dist/agent/orchestrator.js +589 -0
- package/dist/agent/provider-adapter.d.ts +17 -0
- package/dist/agent/provider-adapter.js +35 -3
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +13 -2
- package/dist/agent/retry.js +21 -3
- package/dist/agent/runtime.d.ts +70 -8
- package/dist/agent/runtime.js +205 -34
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.js +30 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/config.d.ts +9 -3
- package/dist/cli/config.js +64 -3
- package/dist/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/repl.js +183 -17
- package/dist/cli/run.d.ts +10 -11
- package/dist/cli/run.js +176 -14
- 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.d.ts +6 -0
- package/dist/context/klyro-md.js +25 -16
- 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/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +99 -0
- package/dist/index.js +93 -4
- package/dist/mcp/client.d.ts +55 -0
- package/dist/mcp/client.js +294 -0
- package/dist/mcp/config.d.ts +40 -0
- package/dist/mcp/config.js +99 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +72 -0
- package/dist/mcp/registry.js +244 -0
- package/dist/mcp/schema.d.ts +18 -0
- package/dist/mcp/schema.js +57 -0
- 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 +1 -0
- package/dist/policy/engine.js +91 -10
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +17 -0
- package/dist/providers/model-info.js +35 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -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 +71 -0
- package/dist/tools/fs/edit-file.js +65 -0
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +66 -0
- package/dist/tools/fs/write-file.js +67 -0
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +12 -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 +1 -0
- package/dist/tools/shell/shell-exec.js +83 -11
- 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 +18 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +55 -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 +23 -12
- package/dist/verification/engine.js +3 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +33 -0
- package/dist/verification/scoped.js +28 -6
- package/package.json +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
import { McpClient, McpError } from './client.js';
|
|
20
|
+
import { loadMcpServers } from './config.js';
|
|
21
|
+
import { evaluateMcpPolicy } from './policy.js';
|
|
22
|
+
import { jsonSchemaToZod } from './schema.js';
|
|
23
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
24
|
+
import { defineTool } from '../tools/types.js';
|
|
25
|
+
/** Hard cap so generated names always fit model/tool-name limits. */
|
|
26
|
+
const MAX_NAME_LEN = 64;
|
|
27
|
+
/** Server part is capped here; the tool part takes whatever remains. */
|
|
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;
|
|
31
|
+
function sanitizePart(s) {
|
|
32
|
+
return s.replace(/[^A-Za-z0-9_]/g, '_');
|
|
33
|
+
}
|
|
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
|
+
*/
|
|
42
|
+
export function sanitizeMcpName(server, tool) {
|
|
43
|
+
const srv = sanitizePart(server).slice(0, MAX_SERVER_PART);
|
|
44
|
+
const maxTool = Math.max(1, MAX_NAME_LEN - 'mcp__'.length - srv.length - '__'.length);
|
|
45
|
+
const tl = sanitizePart(tool).slice(0, maxTool);
|
|
46
|
+
return `mcp__${srv}__${tl}`;
|
|
47
|
+
}
|
|
48
|
+
function errMessage(err) {
|
|
49
|
+
return err instanceof Error ? err.message : String(err);
|
|
50
|
+
}
|
|
51
|
+
async function executeMcpTool(spec, toolDef, client, input, ctx) {
|
|
52
|
+
try {
|
|
53
|
+
// (1) Deny-by-default — runs BEFORE any client I/O.
|
|
54
|
+
const decision = evaluateMcpPolicy(spec.policy, toolDef.name);
|
|
55
|
+
if (!decision.allowed) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: { code: 'POLICY_DENIED', message: decision.reason ?? `mcp tool denied: ${toolDef.name}` },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// (2) Delegate to the server, honouring abort.
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await client.callTool(toolDef.name, input, ctx.signal);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
// (3) Typed server failures keep their code; everything else is TOOL_ERROR.
|
|
68
|
+
// Redact BEFORE the message can reach the model/trace.
|
|
69
|
+
if (err instanceof McpError) {
|
|
70
|
+
return { ok: false, error: { code: err.code, message: redact(err.message) } };
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
|
|
73
|
+
}
|
|
74
|
+
// (4) Server-reported error → TOOL_ERROR, redacted, bounded.
|
|
75
|
+
if (res.isError) {
|
|
76
|
+
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(res.text).slice(0, 2000) } };
|
|
77
|
+
}
|
|
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 };
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// Tool contract: never throw — always return a ToolResult.
|
|
92
|
+
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function registerMcpServers(cfg, opts) {
|
|
96
|
+
const { registry, policy } = opts;
|
|
97
|
+
const factory = opts.clientFactory ?? ((name, spec) => new McpClient(name, spec));
|
|
98
|
+
const registered = [];
|
|
99
|
+
const errors = [];
|
|
100
|
+
const skipped = [];
|
|
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();
|
|
105
|
+
for (const [server, spec] of Object.entries(cfg.servers)) {
|
|
106
|
+
try {
|
|
107
|
+
// enabledServers-style: skip disabled entries silently.
|
|
108
|
+
if (spec.disabled)
|
|
109
|
+
continue;
|
|
110
|
+
// Default factory returns a bare McpClient — connect it here so fake
|
|
111
|
+
// McpClientLike factories (no connect method) also work.
|
|
112
|
+
let client;
|
|
113
|
+
try {
|
|
114
|
+
client = await factory(server, spec);
|
|
115
|
+
const withConnect = client;
|
|
116
|
+
if (typeof withConnect.connect === 'function') {
|
|
117
|
+
await withConnect.connect();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
errors.push({ server, message: errMessage(err) });
|
|
122
|
+
continue; // never throw — keep registering other servers
|
|
123
|
+
}
|
|
124
|
+
let tools;
|
|
125
|
+
try {
|
|
126
|
+
tools = await client.listTools();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
errors.push({ server, message: errMessage(err) });
|
|
130
|
+
try {
|
|
131
|
+
await client.close();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* best-effort */
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
clients.push(client);
|
|
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
|
+
}
|
|
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
|
+
}
|
|
162
|
+
if (registry.get(name)) {
|
|
163
|
+
// Exact-duplicate: the literal name already exists (e.g. a
|
|
164
|
+
// builtin) — skip quietly, first registration wins.
|
|
165
|
+
skipped.push({ name, reason: 'name-collision' });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
claimed.set(name, { server, tool: toolDef.name });
|
|
169
|
+
// Capture per-tool bindings for the closure.
|
|
170
|
+
const boundSpec = spec;
|
|
171
|
+
const boundDef = toolDef;
|
|
172
|
+
const boundClient = client;
|
|
173
|
+
const tool = defineTool({
|
|
174
|
+
name,
|
|
175
|
+
description: `[mcp:${server}] ${boundDef.description ?? boundDef.name}`,
|
|
176
|
+
inputSchema: jsonSchemaToZod(boundDef.inputSchema ?? { type: 'object' }),
|
|
177
|
+
// 'admin': most restrictive class — MCP tools run arbitrary
|
|
178
|
+
// external side effects; nothing in src reads this field yet.
|
|
179
|
+
permission: 'admin',
|
|
180
|
+
execute: (input, ctx) => executeMcpTool(boundSpec, boundDef, boundClient, input, ctx),
|
|
181
|
+
});
|
|
182
|
+
registry.register(tool);
|
|
183
|
+
registered.push(name);
|
|
184
|
+
if (boundSpec.policy?.requireApproval && policy) {
|
|
185
|
+
policy.addAsk(name);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
// Belt-and-braces: registerMcpServers never throws.
|
|
191
|
+
errors.push({ server, message: errMessage(err) });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
registered,
|
|
196
|
+
errors,
|
|
197
|
+
skipped,
|
|
198
|
+
closeAll: async () => {
|
|
199
|
+
await Promise.allSettled(clients.map((c) => c.close()));
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export async function loadAndRegisterMcp(opts) {
|
|
204
|
+
try {
|
|
205
|
+
const cfg = loadMcpServers(opts.cwd);
|
|
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;
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
// Never throws — surface load failures as error entries.
|
|
237
|
+
return {
|
|
238
|
+
registered: [],
|
|
239
|
+
errors: [{ server: '*', message: errMessage(err) }],
|
|
240
|
+
skipped: [],
|
|
241
|
+
closeAll: async () => { },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — JSON Schema → Zod converter for MCP tool input schemas.
|
|
3
|
+
*
|
|
4
|
+
* MCP servers describe inputs with JSON Schema; Klyro tools validate with
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
export declare function jsonSchemaToZod(schema: unknown): z.ZodTypeAny;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1 — JSON Schema → Zod converter for MCP tool input schemas.
|
|
3
|
+
*
|
|
4
|
+
* MCP servers describe inputs with JSON Schema; Klyro tools validate with
|
|
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.
|
|
16
|
+
*/
|
|
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
|
+
}
|
|
28
|
+
export function jsonSchemaToZod(schema) {
|
|
29
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
30
|
+
return z.unknown();
|
|
31
|
+
}
|
|
32
|
+
const s = schema;
|
|
33
|
+
const enumerated = Array.isArray(s['enum']) ? s['enum'] : undefined;
|
|
34
|
+
if (enumerated && enumerated.length > 0 && enumerated.every((v) => typeof v === 'string')) {
|
|
35
|
+
return z.enum(enumerated);
|
|
36
|
+
}
|
|
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) {
|
|
42
|
+
case 'string':
|
|
43
|
+
return z.string();
|
|
44
|
+
case 'number':
|
|
45
|
+
return z.number();
|
|
46
|
+
case 'integer':
|
|
47
|
+
return z.number().int();
|
|
48
|
+
case 'boolean':
|
|
49
|
+
return z.boolean();
|
|
50
|
+
case 'array': {
|
|
51
|
+
const items = jsonSchemaToZod(s['items']);
|
|
52
|
+
return z.array(items);
|
|
53
|
+
}
|
|
54
|
+
default:
|
|
55
|
+
return z.unknown();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -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
|
}
|
|
@@ -50,6 +50,8 @@ export declare class SessionStore {
|
|
|
50
50
|
private writeIndex;
|
|
51
51
|
private projectHash;
|
|
52
52
|
private perProjectIndexPath;
|
|
53
|
+
/** Atomic per-project index write (tmp + fsync + rename). */
|
|
54
|
+
private writePerProjectIndex;
|
|
53
55
|
private titleFor;
|
|
54
56
|
create(opts: {
|
|
55
57
|
cwd: string;
|
|
@@ -61,7 +63,7 @@ export declare class SessionStore {
|
|
|
61
63
|
* continues with full context instead of starting blank.
|
|
62
64
|
*/
|
|
63
65
|
fork(id: string, taskSuffix?: string): Promise<SessionRecord>;
|
|
64
|
-
/** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
|
|
66
|
+
/** Delete a session and all its artifacts (record, transcript, jsonl, lock, indexes). */
|
|
65
67
|
delete(id: string): Promise<boolean>;
|
|
66
68
|
private jsonlPath;
|
|
67
69
|
appendJsonl(id: string, entry: unknown): Promise<void>;
|
|
@@ -77,6 +79,28 @@ export declare class SessionStore {
|
|
|
77
79
|
status?: SessionStatus;
|
|
78
80
|
}): Promise<SessionRecord[]>;
|
|
79
81
|
get(id: string): Promise<SessionRecord | null>;
|
|
80
|
-
/** Atomic append — survives crashes; suitable for audit log. */
|
|
82
|
+
/** Atomic append + fsync — survives crashes; suitable for audit log. */
|
|
81
83
|
static appendJsonl(filePath: string, entry: unknown): Promise<void>;
|
|
82
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Redact text blocks of a stored message at the persist boundary.
|
|
87
|
+
* Handles string content, `{ text }` blocks, arrays of blocks, and plain
|
|
88
|
+
* objects (observation input/output) via a deep walk over string leaves.
|
|
89
|
+
* Only secret-shaped strings (key/token/password patterns, [:=-] shapes)
|
|
90
|
+
* are touched — normal prose passes through unchanged.
|
|
91
|
+
*/
|
|
92
|
+
export declare function redactStoredContent(content: unknown): unknown;
|
|
93
|
+
export interface SessionLockInfo {
|
|
94
|
+
held: boolean;
|
|
95
|
+
pid?: number;
|
|
96
|
+
/** True when the lock holder process is (probably) still running. */
|
|
97
|
+
alive?: boolean;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Cross-process session lock probe. The lock file `<sessionsDir>/<id>.lock`
|
|
101
|
+
* contains the holder's PID. `alive` is resolved via `process.kill(pid, 0)`:
|
|
102
|
+
* EPERM means alive-but-unpermitted (→ true); ESRCH / other errors → false.
|
|
103
|
+
*/
|
|
104
|
+
export declare function readSessionLock(sessionsDir: string, id: string): SessionLockInfo;
|
|
105
|
+
/** Best-effort release of the cross-process session lock. Never throws. */
|
|
106
|
+
export declare function releaseSessionLock(sessionsDir: string, id: string): Promise<void>;
|