klyro 1.0.4 → 1.0.5
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/README.md +29 -0
- package/dist/agent/runtime.js +11 -2
- package/dist/cli/auth.js +11 -3
- package/dist/cli/completion.js +63 -10
- package/dist/cli/doctor.js +13 -0
- package/dist/cli/repl.js +116 -10
- package/dist/cli/run.js +14 -0
- package/dist/cli/slash/parser.d.ts +7 -1
- package/dist/cli/slash/parser.js +37 -10
- package/dist/cli/update.d.ts +8 -4
- package/dist/cli/update.js +50 -7
- package/dist/context/accounting.d.ts +8 -0
- package/dist/context/accounting.js +18 -1
- package/dist/context/compaction.d.ts +1 -0
- package/dist/context/compaction.js +2 -1
- package/dist/index.js +78 -8
- package/dist/mcp/config.d.ts +7 -0
- package/dist/mcp/config.js +45 -0
- package/dist/mcp/registry.js +6 -4
- package/dist/mcp/serve.d.ts +23 -0
- package/dist/mcp/serve.js +111 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +14 -1
- package/dist/policy/secret-redactor.js +4 -1
- package/dist/shared/error-map.d.ts +19 -0
- package/dist/shared/error-map.js +58 -0
- package/dist/tools/lsp/diagnostics.d.ts +35 -4
- package/dist/tools/lsp/diagnostics.js +88 -9
- package/dist/tools/normalize.d.ts +3 -0
- package/dist/tools/normalize.js +8 -5
- package/dist/tools/search/dependencies.d.ts +2 -2
- package/dist/tools/shell/background.d.ts +6 -0
- package/dist/tools/shell/background.js +17 -0
- package/dist/tools/symbols/find-symbol.d.ts +1 -1
- package/dist/tools/symbols/find-symbol.js +8 -6
- package/dist/tools/types.d.ts +8 -1
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +324 -50
- package/dist/tui/app.test.js +42 -3
- package/dist/tui/markdown.js +9 -0
- package/dist/tui/mouse.d.ts +26 -1
- package/dist/tui/mouse.js +104 -6
- package/dist/tui/scroll-flow.test.js +3 -1
- package/dist/tui/tokens.d.ts +6 -6
- package/dist/tui/tokens.js +9 -6
- package/package.json +1 -1
package/dist/cli/update.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* klyro update — check registry for newer version, cached 24h.
|
|
3
3
|
* Env KLYRO_NO_UPDATE_CHECK=1 disables.
|
|
4
4
|
*
|
|
5
|
-
* Integrity: before recommending `npm i`, we verify the tarball
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Integrity: before recommending `npm i`, we verify the tarball against
|
|
6
|
+
* BOTH the registry's SRI digest (sha512/sha256) AND the legacy sha1
|
|
7
|
+
* `dist.shasum` when present — a tampered CDN or MITM registry response
|
|
8
|
+
* must forge two independent digests to push a malicious binary.
|
|
9
|
+
* Downgrade protection: a registry `latest` that is not strictly newer
|
|
10
|
+
* than the running version (semver) is never recommended.
|
|
9
11
|
*/
|
|
10
12
|
import * as fs from 'node:fs/promises';
|
|
11
13
|
import * as path from 'node:path';
|
|
@@ -24,6 +26,28 @@ function cachePath() {
|
|
|
24
26
|
const home = os.homedir() || process.cwd();
|
|
25
27
|
return path.join(home, '.klyro', 'update-cache.json');
|
|
26
28
|
}
|
|
29
|
+
/** Minimal semver compare for `x.y.z[-prerelease]`; null when unparseable. */
|
|
30
|
+
export function compareSemver(a, b) {
|
|
31
|
+
const pa = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(a.trim());
|
|
32
|
+
const pb = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(b.trim());
|
|
33
|
+
if (!pa || !pb)
|
|
34
|
+
return null;
|
|
35
|
+
for (const i of [1, 2, 3]) {
|
|
36
|
+
const d = Number(pa[i]) - Number(pb[i]);
|
|
37
|
+
if (d !== 0)
|
|
38
|
+
return d < 0 ? -1 : 1;
|
|
39
|
+
}
|
|
40
|
+
const ra = pa[4] ?? '';
|
|
41
|
+
const rb = pb[4] ?? '';
|
|
42
|
+
if (ra === rb)
|
|
43
|
+
return 0;
|
|
44
|
+
// A prerelease is older than the release with the same core.
|
|
45
|
+
if (ra === '')
|
|
46
|
+
return 1;
|
|
47
|
+
if (rb === '')
|
|
48
|
+
return -1;
|
|
49
|
+
return ra < rb ? -1 : 1;
|
|
50
|
+
}
|
|
27
51
|
/** SRI string may carry multiple hashes parsable with `pick`; we accept sha512 or sha256. */
|
|
28
52
|
function parseSRI(integrity) {
|
|
29
53
|
if (!integrity)
|
|
@@ -49,7 +73,7 @@ async function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
|
49
73
|
clearTimeout(t);
|
|
50
74
|
}
|
|
51
75
|
}
|
|
52
|
-
/** Download the tarball and confirm
|
|
76
|
+
/** Download the tarball and confirm it matches the registry's SRI digest AND shasum. */
|
|
53
77
|
async function verifyTarballIntegrity(dist) {
|
|
54
78
|
const sri = parseSRI(dist.integrity);
|
|
55
79
|
const tarball = dist.tarball;
|
|
@@ -58,7 +82,15 @@ async function verifyTarballIntegrity(dist) {
|
|
|
58
82
|
const res = await fetchWithTimeout(tarball, TARBALL_TIMEOUT_MS);
|
|
59
83
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
60
84
|
const actual = createHash(sri.algo).update(buf).digest('base64');
|
|
61
|
-
|
|
85
|
+
if (actual !== sri.digest)
|
|
86
|
+
return false;
|
|
87
|
+
// Second independent digest: legacy sha1 shasum, when the registry sends one.
|
|
88
|
+
if (typeof dist.shasum === 'string' && /^[0-9a-f]{40}$/i.test(dist.shasum)) {
|
|
89
|
+
const sha1 = createHash('sha1').update(buf).digest('hex');
|
|
90
|
+
if (sha1.toLowerCase() !== dist.shasum.toLowerCase())
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
62
94
|
}
|
|
63
95
|
export async function checkForUpdate(current) {
|
|
64
96
|
if (process.env.KLYRO_NO_UPDATE_CHECK === '1')
|
|
@@ -78,7 +110,12 @@ export async function checkForUpdate(current) {
|
|
|
78
110
|
const res = await fetchWithTimeout(`${REGISTRY_BASE}/latest`);
|
|
79
111
|
const json = (await res.json());
|
|
80
112
|
const latest = json.version ?? '';
|
|
81
|
-
|
|
113
|
+
// Downgrade protection: only ever recommend a strictly newer version.
|
|
114
|
+
// A registry answering with an older-or-equal `latest` (stale mirror,
|
|
115
|
+
// cache poisoning, downgrade attack) is treated as "no update".
|
|
116
|
+
const cmp = compareSemver(latest, current);
|
|
117
|
+
const isNewer = cmp === null ? latest !== current : cmp > 0;
|
|
118
|
+
if (latest && isNewer) {
|
|
82
119
|
// Verify the tarball's integrity before caching/recommending this version.
|
|
83
120
|
const verRes = await fetchWithTimeout(`${REGISTRY_BASE}/${encodeURIComponent(latest)}`);
|
|
84
121
|
const verJson = (await verRes.json());
|
|
@@ -89,6 +126,12 @@ export async function checkForUpdate(current) {
|
|
|
89
126
|
await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest }), 'utf-8');
|
|
90
127
|
return latest;
|
|
91
128
|
}
|
|
129
|
+
if (latest && cmp !== null && cmp <= 0) {
|
|
130
|
+
// Refresh the negative cache so a poisoned answer isn't re-fetched
|
|
131
|
+
// every invocation for the next 24h.
|
|
132
|
+
await fs.mkdir(path.dirname(cache), { recursive: true }).catch(() => undefined);
|
|
133
|
+
await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest: current }), 'utf-8').catch(() => undefined);
|
|
134
|
+
}
|
|
92
135
|
}
|
|
93
136
|
catch {
|
|
94
137
|
// network failure — silent
|
|
@@ -12,5 +12,13 @@ export declare function accounting(system: string | undefined, messages: Message
|
|
|
12
12
|
reserveOutput?: number;
|
|
13
13
|
toolResultMax?: number;
|
|
14
14
|
compactAt?: number;
|
|
15
|
+
model?: string;
|
|
15
16
|
}): ContextAccounting;
|
|
17
|
+
/**
|
|
18
|
+
* Input-token budget for a model: its context window minus the output
|
|
19
|
+
* reserve, clamped to the legacy 120k ceiling and a 4k usable floor so
|
|
20
|
+
* tiny windows still function. Unknown models use the registry fallback
|
|
21
|
+
* window (100k); a missing model name keeps the legacy 120k.
|
|
22
|
+
*/
|
|
23
|
+
export declare function capForModel(model: string | undefined, reserveOutput?: number): number;
|
|
16
24
|
export declare function contextMeter(pct: number): string;
|
|
@@ -3,15 +3,32 @@
|
|
|
3
3
|
* Live token estimate, ctx%, compactAt, reserveOutput, toolResultMax
|
|
4
4
|
*/
|
|
5
5
|
import { totalTokens } from './tokenizer.js';
|
|
6
|
+
import { getModelInfo } from '../providers/model-info.js';
|
|
6
7
|
export function accounting(system, messages, opts = {}) {
|
|
7
|
-
const cap = opts.cap ?? 120_000;
|
|
8
8
|
const reserveOutput = opts.reserveOutput ?? 16_000;
|
|
9
|
+
// Window-aware default: the legacy 120k ceiling overflows small-window
|
|
10
|
+
// models (e.g. 8k local models) and wastes large ones — size to the model.
|
|
11
|
+
const cap = opts.cap ?? capForModel(opts.model, reserveOutput);
|
|
9
12
|
const toolResultMax = opts.toolResultMax ?? 2000;
|
|
10
13
|
const compactAt = opts.compactAt ?? 0.8;
|
|
11
14
|
const used = totalTokens(system, messages);
|
|
12
15
|
const pct = Math.round((used / cap) * 100);
|
|
13
16
|
return { used, cap, pct, reserveOutput, compactAt, toolResultMax };
|
|
14
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Input-token budget for a model: its context window minus the output
|
|
20
|
+
* reserve, clamped to the legacy 120k ceiling and a 4k usable floor so
|
|
21
|
+
* tiny windows still function. Unknown models use the registry fallback
|
|
22
|
+
* window (100k); a missing model name keeps the legacy 120k.
|
|
23
|
+
*/
|
|
24
|
+
export function capForModel(model, reserveOutput = 16_000) {
|
|
25
|
+
if (!model)
|
|
26
|
+
return 120_000;
|
|
27
|
+
const window = getModelInfo(model).contextWindow;
|
|
28
|
+
if (!Number.isFinite(window) || window <= 0)
|
|
29
|
+
return 120_000;
|
|
30
|
+
return Math.max(4_000, Math.min(120_000, Math.floor(window - reserveOutput)));
|
|
31
|
+
}
|
|
15
32
|
export function contextMeter(pct) {
|
|
16
33
|
const filled = Math.round((pct / 100) * 20);
|
|
17
34
|
return `${'▰'.repeat(filled)}${'▱'.repeat(20 - filled)}`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { compressTranscript } from './tokenizer.js';
|
|
2
|
+
import { capForModel } from './accounting.js';
|
|
2
3
|
export async function compact(messages, opts) {
|
|
3
|
-
const cap = opts.cap ??
|
|
4
|
+
const cap = opts.cap ?? capForModel(opts.model);
|
|
4
5
|
// (a) elide old tool results
|
|
5
6
|
const elided = compressTranscript(opts.system, messages, { total: cap, reservedOutput: 16_000 });
|
|
6
7
|
if (opts.checkpointedFiles && elided.dropped > 0) {
|
package/dist/index.js
CHANGED
|
@@ -56,7 +56,7 @@ async function main() {
|
|
|
56
56
|
.option('--verbose', 'Verbose output')
|
|
57
57
|
.option('--quiet', 'Suppress non-essential output')
|
|
58
58
|
.option('--json', 'Force JSON output where supported')
|
|
59
|
-
.option('--yes', 'Auto-approve prompts
|
|
59
|
+
.option('--yes', 'Auto-approve commit prompts (scope: `klyro commit` only)')
|
|
60
60
|
.option('--no-color', 'Disable colored output')
|
|
61
61
|
.option('-p, --print <prompt>', 'Headless one-shot prompt (alias for run, --output json for machine)')
|
|
62
62
|
.option('--output-format <fmt>', 'Headless output format: text|json|stream-json (default text)')
|
|
@@ -552,16 +552,58 @@ async function main() {
|
|
|
552
552
|
}
|
|
553
553
|
const rec = await store.get(full);
|
|
554
554
|
const msgs = await store.loadMessages(full);
|
|
555
|
+
const obs = await store.loadObservations(full);
|
|
555
556
|
const out = file ?? `${full}.export.json`;
|
|
556
|
-
await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs }, null, 2));
|
|
557
|
+
await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2));
|
|
557
558
|
process.stdout.write(`exported ${full} → ${out}\n`);
|
|
558
559
|
});
|
|
559
|
-
sessions.command('import <file>').description('Import session from file').action(async (file) => {
|
|
560
|
-
|
|
560
|
+
sessions.command('import <file>').description('Import session from file (restores record + messages + observations)').action(async (file) => {
|
|
561
|
+
let data;
|
|
562
|
+
try {
|
|
563
|
+
data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
process.stderr.write(`klyro: cannot import ${file}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
567
|
+
process.exit(2);
|
|
568
|
+
}
|
|
569
|
+
const rec = data.record ?? {};
|
|
561
570
|
const { getDefaultSessionStore } = await import('./persistence/session.js');
|
|
562
571
|
const store = getDefaultSessionStore();
|
|
563
|
-
const
|
|
564
|
-
process.
|
|
572
|
+
const cfg = (rec.config && typeof rec.config === 'object' ? rec.config : { model: 'imported', maxSteps: 30 });
|
|
573
|
+
const created = await store.create({ cwd: typeof rec.cwd === 'string' ? rec.cwd : process.cwd(), task: typeof rec.task === 'string' ? rec.task : 'imported', config: cfg });
|
|
574
|
+
// Restore the transcript — previously this was silently dropped (lossy
|
|
575
|
+
// import). Messages/observations go through append* so at-rest redaction
|
|
576
|
+
// still applies. Malformed entries fail loudly instead of half-importing.
|
|
577
|
+
const d = data;
|
|
578
|
+
let restored = 0;
|
|
579
|
+
if (d.messages !== undefined) {
|
|
580
|
+
if (!Array.isArray(d.messages)) {
|
|
581
|
+
process.stderr.write(`klyro: import failed: "messages" is not an array in ${file}\n`);
|
|
582
|
+
process.exit(2);
|
|
583
|
+
}
|
|
584
|
+
for (const m of d.messages) {
|
|
585
|
+
if (!m || typeof m !== 'object' || typeof m.role !== 'string' || !('content' in m)) {
|
|
586
|
+
process.stderr.write(`klyro: import failed: malformed message entry in ${file}\n`);
|
|
587
|
+
process.exit(2);
|
|
588
|
+
}
|
|
589
|
+
await store.appendMessage(created.id, m);
|
|
590
|
+
restored++;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (d.observations !== undefined) {
|
|
594
|
+
if (!Array.isArray(d.observations)) {
|
|
595
|
+
process.stderr.write(`klyro: import failed: "observations" is not an array in ${file}\n`);
|
|
596
|
+
process.exit(2);
|
|
597
|
+
}
|
|
598
|
+
for (const o of d.observations) {
|
|
599
|
+
if (!o || typeof o !== 'object') {
|
|
600
|
+
process.stderr.write(`klyro: import failed: malformed observation entry in ${file}\n`);
|
|
601
|
+
process.exit(2);
|
|
602
|
+
}
|
|
603
|
+
await store.appendObservation(created.id, o);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
process.stdout.write(`imported → ${created.id} (${restored} messages restored)\n`);
|
|
565
607
|
});
|
|
566
608
|
sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => {
|
|
567
609
|
const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
|
|
@@ -612,7 +654,31 @@ async function main() {
|
|
|
612
654
|
process.stdout.write(`${name} source=${source}${spec?.disabled ? ' disabled' : ''}\n`);
|
|
613
655
|
}
|
|
614
656
|
});
|
|
615
|
-
mcp.command('add <name> <
|
|
657
|
+
mcp.command('add <name> <command> [args...]').description('Add a project MCP server to .mcp.json (stdio command)').action(async (name, command, args) => {
|
|
658
|
+
const { addProjectServer, projectMcpPath } = await import('./mcp/config.js');
|
|
659
|
+
try {
|
|
660
|
+
addProjectServer(process.cwd(), name, { command, ...(args && args.length > 0 ? { args } : {}) });
|
|
661
|
+
process.stdout.write(`added mcp server "${name}" → ${projectMcpPath(process.cwd())}\n`);
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
process.stderr.write(`klyro: mcp add failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
665
|
+
process.exit(2);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
mcp.command('remove <name>').description('Remove a project MCP server from .mcp.json').action(async (name) => {
|
|
669
|
+
const { removeProjectServer, projectMcpPath } = await import('./mcp/config.js');
|
|
670
|
+
try {
|
|
671
|
+
if (!removeProjectServer(process.cwd(), name)) {
|
|
672
|
+
process.stderr.write(`klyro: mcp server not found in ${projectMcpPath(process.cwd())}: ${name}\n`);
|
|
673
|
+
process.exit(2);
|
|
674
|
+
}
|
|
675
|
+
process.stdout.write(`removed mcp server "${name}"\n`);
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
process.stderr.write(`klyro: mcp remove failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
679
|
+
process.exit(2);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
616
682
|
mcp.command('probe <name>').description('Connect to an MCP server (15s timeout), list its tools, print count+names').action(async (name) => {
|
|
617
683
|
const { loadMcpServers } = await import('./mcp/config.js');
|
|
618
684
|
const cfg = loadMcpServers(process.cwd());
|
|
@@ -647,7 +713,11 @@ async function main() {
|
|
|
647
713
|
catch { /* ignore */ }
|
|
648
714
|
}
|
|
649
715
|
});
|
|
650
|
-
mcp.command('serve').description('Serve as MCP server').action(async () => {
|
|
716
|
+
mcp.command('serve').description('Serve builtin tools as an MCP server over stdio (policy-gated)').action(async () => {
|
|
717
|
+
const { serveStdio } = await import('./mcp/serve.js');
|
|
718
|
+
const code = await serveStdio(process.cwd());
|
|
719
|
+
process.exit(code);
|
|
720
|
+
});
|
|
651
721
|
// 10.2 — Hooks: list configured preToolUse/postToolUse hooks.
|
|
652
722
|
program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks` or `klyro hooks list` prints configured hooks').action(async (cmd) => {
|
|
653
723
|
if (cmd && cmd !== 'list') {
|
package/dist/mcp/config.d.ts
CHANGED
|
@@ -38,3 +38,10 @@ export declare function expandEnv(value: string): string;
|
|
|
38
38
|
export declare function loadMcpServers(cwd: string): McpServersConfig;
|
|
39
39
|
/** Servers eligible for connection (configured and not disabled). */
|
|
40
40
|
export declare function enabledServers(cfg: McpServersConfig): Record<string, McpServerSpec>;
|
|
41
|
+
/** Server names are bounded: the registry builds `mcp__<server>__<tool>` (≤64 chars). */
|
|
42
|
+
export declare const MCP_NAME_RE: RegExp;
|
|
43
|
+
export declare function projectMcpPath(cwd: string): string;
|
|
44
|
+
/** Add (or reject duplicates of) a project-level MCP server. Throws on invalid input. */
|
|
45
|
+
export declare function addProjectServer(cwd: string, name: string, spec: unknown): void;
|
|
46
|
+
/** Remove a project-level MCP server. Returns false when absent. */
|
|
47
|
+
export declare function removeProjectServer(cwd: string, name: string): boolean;
|
package/dist/mcp/config.js
CHANGED
|
@@ -97,3 +97,48 @@ export function enabledServers(cfg) {
|
|
|
97
97
|
}
|
|
98
98
|
return out;
|
|
99
99
|
}
|
|
100
|
+
/** Server names are bounded: the registry builds `mcp__<server>__<tool>` (≤64 chars). */
|
|
101
|
+
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,20}$/;
|
|
102
|
+
export function projectMcpPath(cwd) {
|
|
103
|
+
return path.join(cwd, '.mcp.json');
|
|
104
|
+
}
|
|
105
|
+
function readProjectDoc(cwd) {
|
|
106
|
+
const raw = readJsonFile(projectMcpPath(cwd));
|
|
107
|
+
const doc = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {});
|
|
108
|
+
const servers = normalizeRaw(doc);
|
|
109
|
+
return { doc, servers: { ...servers } };
|
|
110
|
+
}
|
|
111
|
+
function writeProjectDoc(cwd, doc, servers) {
|
|
112
|
+
const next = { ...doc };
|
|
113
|
+
if ('mcpServers' in next)
|
|
114
|
+
next['mcpServers'] = servers;
|
|
115
|
+
else
|
|
116
|
+
next['servers'] = servers;
|
|
117
|
+
if (Object.keys(next).length === 0)
|
|
118
|
+
next['mcpServers'] = servers;
|
|
119
|
+
const tmp = `${projectMcpPath(cwd)}.tmp-${process.pid}`;
|
|
120
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
|
121
|
+
fs.renameSync(tmp, projectMcpPath(cwd));
|
|
122
|
+
}
|
|
123
|
+
/** Add (or reject duplicates of) a project-level MCP server. Throws on invalid input. */
|
|
124
|
+
export function addProjectServer(cwd, name, spec) {
|
|
125
|
+
if (!MCP_NAME_RE.test(name))
|
|
126
|
+
throw new Error(`invalid server name "${name}" (want 1-20 chars of A-Za-z0-9_-)`);
|
|
127
|
+
const parsed = McpServerSpecSchema.safeParse(spec);
|
|
128
|
+
if (!parsed.success)
|
|
129
|
+
throw new Error(`invalid server spec: ${parsed.error.issues.map((i) => i.message).join('; ')}`);
|
|
130
|
+
const { doc, servers } = readProjectDoc(cwd);
|
|
131
|
+
if (name in servers)
|
|
132
|
+
throw new Error(`server "${name}" already configured in ${projectMcpPath(cwd)} (remove it first)`);
|
|
133
|
+
servers[name] = parsed.data;
|
|
134
|
+
writeProjectDoc(cwd, doc, servers);
|
|
135
|
+
}
|
|
136
|
+
/** Remove a project-level MCP server. Returns false when absent. */
|
|
137
|
+
export function removeProjectServer(cwd, name) {
|
|
138
|
+
const { doc, servers } = readProjectDoc(cwd);
|
|
139
|
+
if (!(name in servers))
|
|
140
|
+
return false;
|
|
141
|
+
delete servers[name];
|
|
142
|
+
writeProjectDoc(cwd, doc, servers);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
package/dist/mcp/registry.js
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
* 3. `requireApproval` servers add an ask-rule to the PolicyEngine so the
|
|
12
12
|
* runtime loop prompts before executing.
|
|
13
13
|
*
|
|
14
|
-
* Permission class:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Permission class: the runtime passes each tool's `permission` into
|
|
15
|
+
* `PolicyEngine.evaluate`, and `execute`/`admin` tools with no explicit
|
|
16
|
+
* allow rule fall through to ask (interactive) or deny (headless). We
|
|
17
|
+
* pick the most restrictive class, 'admin' — the same class as
|
|
18
|
+
* `spawn_agent`, since MCP tools execute arbitrary external side effects
|
|
19
|
+
* (read/write/network) outside our control.
|
|
18
20
|
*
|
|
19
21
|
* Debug capture: when `KLYRO_MCP_DEBUG=1` is set, every MCP tool success
|
|
20
22
|
* AND error ALSO writes the UNREDACTED raw JSON payload (pre-redaction,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ToolRegistry } from '../tools/registry.js';
|
|
2
|
+
import type { ToolContext } from '../tools/types.js';
|
|
3
|
+
import { PolicyEngine } from '../policy/engine.js';
|
|
4
|
+
interface JsonRpcRequest {
|
|
5
|
+
jsonrpc?: string;
|
|
6
|
+
id?: string | number | null;
|
|
7
|
+
method?: string;
|
|
8
|
+
params?: {
|
|
9
|
+
name?: string;
|
|
10
|
+
arguments?: unknown;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface ServeDeps {
|
|
14
|
+
registry?: ToolRegistry;
|
|
15
|
+
policy?: PolicyEngine;
|
|
16
|
+
ctx?: ToolContext;
|
|
17
|
+
}
|
|
18
|
+
export declare function makeServeDeps(cwd: string, overrides?: ServeDeps): Required<ServeDeps>;
|
|
19
|
+
/** Pure request handler — unit-tested without stdio. Returns null for notifications. */
|
|
20
|
+
export declare function handleMcpRequest(deps: Required<ServeDeps>, msg: JsonRpcRequest): Promise<Record<string, unknown> | null>;
|
|
21
|
+
/** Stdio loop: one JSON-RPC message per line on stdin, responses on stdout. */
|
|
22
|
+
export declare function serveStdio(cwd: string): Promise<number>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `klyro mcp serve` — expose the builtin tool registry as a minimal MCP
|
|
3
|
+
* server over stdio (newline-delimited JSON-RPC 2.0).
|
|
4
|
+
*
|
|
5
|
+
* Supported methods: `initialize`, `ping`, `tools/list`, `tools/call`.
|
|
6
|
+
* Every call is gated by the PolicyEngine (default config + builtin rules)
|
|
7
|
+
* in headless mode: `allow` runs, `deny`/`ask` are refused as tool errors
|
|
8
|
+
* (serve cannot prompt, so nothing privileged runs without an explicit
|
|
9
|
+
* allow rule) — serving never bypasses policy.
|
|
10
|
+
*/
|
|
11
|
+
import * as readline from 'node:readline';
|
|
12
|
+
import { builtinRegistry } from '../tools/registry.js';
|
|
13
|
+
import { PolicyEngine, builtinRules, DEFAULT_POLICY_CONFIG } from '../policy/engine.js';
|
|
14
|
+
import { readVersion } from '../version.js';
|
|
15
|
+
export function makeServeDeps(cwd, overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
registry: overrides.registry ?? builtinRegistry(),
|
|
18
|
+
policy: overrides.policy ?? new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG),
|
|
19
|
+
ctx: overrides.ctx ?? { cwd, env: process.env, nonInteractive: true },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Pure request handler — unit-tested without stdio. Returns null for notifications. */
|
|
23
|
+
export async function handleMcpRequest(deps, msg) {
|
|
24
|
+
const id = msg.id ?? null;
|
|
25
|
+
if (msg.method === undefined || typeof msg.method !== 'string') {
|
|
26
|
+
if (id === null || id === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
return { jsonrpc: '2.0', id, error: { code: -32600, message: 'Invalid Request' } };
|
|
29
|
+
}
|
|
30
|
+
if (id === null || id === undefined)
|
|
31
|
+
return null; // notification — no response
|
|
32
|
+
if (msg.method === 'initialize') {
|
|
33
|
+
return {
|
|
34
|
+
jsonrpc: '2.0', id,
|
|
35
|
+
result: {
|
|
36
|
+
protocolVersion: '2024-11-05',
|
|
37
|
+
capabilities: { tools: {} },
|
|
38
|
+
serverInfo: { name: 'klyro', version: readVersion() },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (msg.method === 'ping')
|
|
43
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
44
|
+
if (msg.method === 'tools/list') {
|
|
45
|
+
const schemas = deps.registry.jsonSchemas();
|
|
46
|
+
return {
|
|
47
|
+
jsonrpc: '2.0', id,
|
|
48
|
+
result: {
|
|
49
|
+
tools: deps.registry.list().map((t) => ({
|
|
50
|
+
name: t.name,
|
|
51
|
+
description: t.description,
|
|
52
|
+
inputSchema: schemas[t.name] ?? { type: 'object' },
|
|
53
|
+
})),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (msg.method === 'tools/call') {
|
|
58
|
+
const name = msg.params?.name;
|
|
59
|
+
const args = msg.params?.arguments ?? {};
|
|
60
|
+
if (typeof name !== 'string' || !deps.registry.get(name)) {
|
|
61
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${String(name)}` } };
|
|
62
|
+
}
|
|
63
|
+
const decision = await deps.policy.evaluate({ name, input: (args ?? {}), permission: deps.registry.get(name)?.permission }, { cwd: deps.ctx.cwd, nonInteractive: true });
|
|
64
|
+
if (decision.action !== 'allow') {
|
|
65
|
+
return {
|
|
66
|
+
jsonrpc: '2.0', id,
|
|
67
|
+
result: {
|
|
68
|
+
content: [{ type: 'text', text: `POLICY_DENIED: ${decision.action === 'deny' ? decision.reason ?? 'denied' : 'approval required (non-interactive serve cannot prompt)'}` }],
|
|
69
|
+
isError: true,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const res = await deps.registry.execute(name, args, deps.ctx);
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const e = res.error;
|
|
76
|
+
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `${e.code ?? 'ERROR'}: ${e.message ?? ''}` }], isError: true } };
|
|
77
|
+
}
|
|
78
|
+
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: typeof res.value === 'string' ? res.value : JSON.stringify(res.value) }] } };
|
|
79
|
+
}
|
|
80
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
|
|
81
|
+
}
|
|
82
|
+
/** Stdio loop: one JSON-RPC message per line on stdin, responses on stdout. */
|
|
83
|
+
export async function serveStdio(cwd) {
|
|
84
|
+
const deps = makeServeDeps(cwd);
|
|
85
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
86
|
+
for await (const line of rl) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed)
|
|
89
|
+
continue;
|
|
90
|
+
let msg;
|
|
91
|
+
try {
|
|
92
|
+
msg = JSON.parse(trimmed);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const res = await handleMcpRequest(deps, msg);
|
|
100
|
+
if (res)
|
|
101
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const id = msg.id ?? null;
|
|
105
|
+
if (id !== null && id !== undefined) {
|
|
106
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32603, message: err instanceof Error ? err.message : String(err) } }) + '\n');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export interface ToolCallLike {
|
|
|
20
20
|
name: string;
|
|
21
21
|
/** Parsed tool input. */
|
|
22
22
|
input: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Tool risk class from the registry (`read|edit|execute|admin`).
|
|
25
|
+
* The runtime always passes this; when present, `execute`/`admin`
|
|
26
|
+
* tools fall through to ask/deny instead of the legacy default-allow
|
|
27
|
+
* (see evaluate). Omitted in unit tests → legacy default-allow.
|
|
28
|
+
*/
|
|
29
|
+
permission?: 'read' | 'edit' | 'execute' | 'admin';
|
|
23
30
|
}
|
|
24
31
|
export interface PolicyContext {
|
|
25
32
|
cwd: string;
|
|
@@ -55,7 +62,10 @@ export interface PolicyRule {
|
|
|
55
62
|
export declare const DEFAULT_POLICY_CONFIG: PolicyConfig;
|
|
56
63
|
/**
|
|
57
64
|
* Compose multiple rules. The first rule to return a Decision wins.
|
|
58
|
-
* If none return a Decision,
|
|
65
|
+
* If none return a Decision, privileged tools (`execute`/`admin`, when
|
|
66
|
+
* the caller passes `permission`) fall through to ask (interactive) or
|
|
67
|
+
* deny (headless) instead of allow; everything else defaults to `allow`.
|
|
68
|
+
* `auto` mode keeps the legacy allow-everything behavior.
|
|
59
69
|
*/
|
|
60
70
|
export declare class PolicyEngine {
|
|
61
71
|
private readonly rules;
|
package/dist/policy/engine.js
CHANGED
|
@@ -44,7 +44,10 @@ export const DEFAULT_POLICY_CONFIG = {
|
|
|
44
44
|
};
|
|
45
45
|
/**
|
|
46
46
|
* Compose multiple rules. The first rule to return a Decision wins.
|
|
47
|
-
* If none return a Decision,
|
|
47
|
+
* If none return a Decision, privileged tools (`execute`/`admin`, when
|
|
48
|
+
* the caller passes `permission`) fall through to ask (interactive) or
|
|
49
|
+
* deny (headless) instead of allow; everything else defaults to `allow`.
|
|
50
|
+
* `auto` mode keeps the legacy allow-everything behavior.
|
|
48
51
|
*/
|
|
49
52
|
export class PolicyEngine {
|
|
50
53
|
rules;
|
|
@@ -116,6 +119,16 @@ export class PolicyEngine {
|
|
|
116
119
|
if (d)
|
|
117
120
|
return d;
|
|
118
121
|
}
|
|
122
|
+
// Privileged-class default: an `execute`/`admin` tool that no rule
|
|
123
|
+
// explicitly allowed must not run silently. Interactive sessions get
|
|
124
|
+
// an approval prompt; headless sessions get a denial naming the
|
|
125
|
+
// escape hatch (an explicit `tool`/`tool(glob)` allow rule).
|
|
126
|
+
if (ctx.config.mode !== 'auto' && (call.permission === 'execute' || call.permission === 'admin')) {
|
|
127
|
+
if (ctx.nonInteractive) {
|
|
128
|
+
return { action: 'deny', reason: `${call.name} is a privileged ${call.permission} tool — pre-approve with an allow rule (e.g. "${call.name}")` };
|
|
129
|
+
}
|
|
130
|
+
return { action: 'ask', reason: `${call.name} is a privileged ${call.permission} tool and needs approval` };
|
|
131
|
+
}
|
|
119
132
|
return { action: 'allow' };
|
|
120
133
|
}
|
|
121
134
|
evaluateGlobRules(call) {
|
|
@@ -12,7 +12,10 @@ import { Transform } from 'node:stream';
|
|
|
12
12
|
const PATTERNS = [
|
|
13
13
|
{ name: 'aws-key', re: /AKIA[0-9A-Z]{16}/g },
|
|
14
14
|
{ name: 'aws-secret', re: /(?:aws_secret_access_key|secret)\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi },
|
|
15
|
-
|
|
15
|
+
// Long base64-ish runs must BOTH contain a +/= (excludes pure-hex SHAs
|
|
16
|
+
// and hashes) AND a digit (excludes letter-only words/sentences that
|
|
17
|
+
// happen to be long). Genuine secrets mix classes; prose rarely does.
|
|
18
|
+
{ name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])(?=[A-Za-z0-9/+=]*[0-9])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
|
|
16
19
|
{ name: 'pem-block', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g },
|
|
17
20
|
{ name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,255}/g },
|
|
18
21
|
{ name: 'slack-token', re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single mapper unifying the three error dialects:
|
|
3
|
+
* - ToolErrorCode (tools/normalize.ts — per-tool failures)
|
|
4
|
+
* - FailureClass (verification/classify.ts — verify repair classes)
|
|
5
|
+
* → KlyroErrorCode (shared/errors.ts — harness-wide + exit codes)
|
|
6
|
+
*
|
|
7
|
+
* Type-only imports keep this module cycle-free: it is safe to import
|
|
8
|
+
* from tools/, verification/, and cli/ alike.
|
|
9
|
+
*/
|
|
10
|
+
import { EXIT_CODE, type KlyroErrorCode } from './errors.js';
|
|
11
|
+
import type { FailureClass } from '../verification/classify.js';
|
|
12
|
+
export { EXIT_CODE };
|
|
13
|
+
export type { KlyroErrorCode };
|
|
14
|
+
/** Map any tool-layer error code to its harness-wide KlyroErrorCode. */
|
|
15
|
+
export declare function toolErrorToKlyroCode(code: string): KlyroErrorCode;
|
|
16
|
+
/** Map a verification failure class to its harness-wide code. */
|
|
17
|
+
export declare function failureClassToKlyroCode(cls: FailureClass): KlyroErrorCode;
|
|
18
|
+
/** Exit code for any mapped error: single entry point for CLI mapping. */
|
|
19
|
+
export declare function exitCodeFor(code: KlyroErrorCode): number;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single mapper unifying the three error dialects:
|
|
3
|
+
* - ToolErrorCode (tools/normalize.ts — per-tool failures)
|
|
4
|
+
* - FailureClass (verification/classify.ts — verify repair classes)
|
|
5
|
+
* → KlyroErrorCode (shared/errors.ts — harness-wide + exit codes)
|
|
6
|
+
*
|
|
7
|
+
* Type-only imports keep this module cycle-free: it is safe to import
|
|
8
|
+
* from tools/, verification/, and cli/ alike.
|
|
9
|
+
*/
|
|
10
|
+
import { EXIT_CODE } from './errors.js';
|
|
11
|
+
export { EXIT_CODE };
|
|
12
|
+
/** Map any tool-layer error code to its harness-wide KlyroErrorCode. */
|
|
13
|
+
export function toolErrorToKlyroCode(code) {
|
|
14
|
+
switch (code) {
|
|
15
|
+
case 'NOT_FOUND':
|
|
16
|
+
case 'COMMAND_NOT_FOUND':
|
|
17
|
+
case 'MATCH_NOT_FOUND':
|
|
18
|
+
case 'UNKNOWN_TOOL':
|
|
19
|
+
return 'TOOL_NOT_FOUND';
|
|
20
|
+
case 'PERMISSION_DENIED':
|
|
21
|
+
case 'COMMAND_DENIED':
|
|
22
|
+
case 'POLICY_DENIED':
|
|
23
|
+
return 'TOOL_DENIED';
|
|
24
|
+
case 'PATH_ESCAPE':
|
|
25
|
+
return 'PATH_ESCAPE';
|
|
26
|
+
case 'TIMEOUT':
|
|
27
|
+
return 'PROVIDER_TIMEOUT';
|
|
28
|
+
case 'INVALID_INPUT':
|
|
29
|
+
case 'INVALID_PATCH':
|
|
30
|
+
case 'HUNK_MISMATCH':
|
|
31
|
+
case 'MATCH_AMBIGUOUS':
|
|
32
|
+
return 'CONFIG_INVALID';
|
|
33
|
+
case 'ABORTED':
|
|
34
|
+
case 'STALE':
|
|
35
|
+
case 'EXIT_NONZERO':
|
|
36
|
+
case 'IO_ERROR':
|
|
37
|
+
case 'INTERNAL':
|
|
38
|
+
default:
|
|
39
|
+
return 'UNKNOWN';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Map a verification failure class to its harness-wide code. */
|
|
43
|
+
export function failureClassToKlyroCode(cls) {
|
|
44
|
+
switch (cls) {
|
|
45
|
+
case 'introduced':
|
|
46
|
+
return 'VERIFY_FAILED';
|
|
47
|
+
case 'pre_existing':
|
|
48
|
+
case 'flaky':
|
|
49
|
+
// Not the agent's fault — surfaced as advisory, not a hard failure.
|
|
50
|
+
return 'UNKNOWN';
|
|
51
|
+
case 'env':
|
|
52
|
+
return 'CONFIG_INVALID';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Exit code for any mapped error: single entry point for CLI mapping. */
|
|
56
|
+
export function exitCodeFor(code) {
|
|
57
|
+
return EXIT_CODE[code] ?? 1;
|
|
58
|
+
}
|