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.
Files changed (46) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/runtime.js +11 -2
  3. package/dist/cli/auth.js +11 -3
  4. package/dist/cli/completion.js +63 -10
  5. package/dist/cli/doctor.js +13 -0
  6. package/dist/cli/repl.js +116 -10
  7. package/dist/cli/run.js +14 -0
  8. package/dist/cli/slash/parser.d.ts +7 -1
  9. package/dist/cli/slash/parser.js +37 -10
  10. package/dist/cli/update.d.ts +8 -4
  11. package/dist/cli/update.js +50 -7
  12. package/dist/context/accounting.d.ts +8 -0
  13. package/dist/context/accounting.js +18 -1
  14. package/dist/context/compaction.d.ts +1 -0
  15. package/dist/context/compaction.js +2 -1
  16. package/dist/index.js +78 -8
  17. package/dist/mcp/config.d.ts +7 -0
  18. package/dist/mcp/config.js +45 -0
  19. package/dist/mcp/registry.js +6 -4
  20. package/dist/mcp/serve.d.ts +23 -0
  21. package/dist/mcp/serve.js +111 -0
  22. package/dist/policy/engine.d.ts +11 -1
  23. package/dist/policy/engine.js +14 -1
  24. package/dist/policy/secret-redactor.js +4 -1
  25. package/dist/shared/error-map.d.ts +19 -0
  26. package/dist/shared/error-map.js +58 -0
  27. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  28. package/dist/tools/lsp/diagnostics.js +88 -9
  29. package/dist/tools/normalize.d.ts +3 -0
  30. package/dist/tools/normalize.js +8 -5
  31. package/dist/tools/search/dependencies.d.ts +2 -2
  32. package/dist/tools/shell/background.d.ts +6 -0
  33. package/dist/tools/shell/background.js +17 -0
  34. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  35. package/dist/tools/symbols/find-symbol.js +8 -6
  36. package/dist/tools/types.d.ts +8 -1
  37. package/dist/tui/app.d.ts +2 -0
  38. package/dist/tui/app.js +324 -50
  39. package/dist/tui/app.test.js +42 -3
  40. package/dist/tui/markdown.js +9 -0
  41. package/dist/tui/mouse.d.ts +26 -1
  42. package/dist/tui/mouse.js +104 -6
  43. package/dist/tui/scroll-flow.test.js +3 -1
  44. package/dist/tui/tokens.d.ts +6 -6
  45. package/dist/tui/tokens.js +9 -6
  46. package/package.json +1 -1
@@ -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's SRI hash
6
- * (sha512) against the registry's recorded `dist.integrity`. An install is
7
- * only recommended when the download hash matches, so a tampered CDN or
8
- * MITM registry response can't push a malicious binary to the operator.
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 its hash equals the registry's SRI digest. */
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
- return actual === sri.digest;
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
- if (latest && latest !== current) {
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)}`;
@@ -15,4 +15,5 @@ export declare function compact(messages: Message[], opts: {
15
15
  focus?: string;
16
16
  checkpointedFiles?: string[];
17
17
  summarizeFn?: (prompt: string) => Promise<string>;
18
+ model?: string;
18
19
  }): Promise<CompactionResult>;
@@ -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 ?? 120_000;
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 where possible')
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
- const data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
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 rec = await store.create({ cwd: data.record?.cwd ?? process.cwd(), task: data.record?.task ?? 'imported', config: data.record?.config ?? { model: 'imported', maxSteps: 30 } });
564
- process.stdout.write(`imported → ${rec.id}\n`);
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> <url>').description('Add MCP server').action(async (name) => { process.stdout.write(`added mcp ${name} (stub)\n`); });
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 () => { process.stdout.write('klyro mcp serve — exposing tools (stub)\n'); });
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') {
@@ -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;
@@ -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
+ }
@@ -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: 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.
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
+ }
@@ -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, the default is `allow`.
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;
@@ -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, the default is `allow`.
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
- { name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
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
+ }